@myapihq/cli 1.2.9 → 1.3.1

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,288 @@
1
+ import { task as sdkTask } from '@myapihq/sdk';
2
+ import { requireConfig } from '../config.js';
3
+ import { success, error, printTable, info, printJson } from '../output.js';
4
+ import { formatDate } from '../utils.js';
5
+ import { requireOrg, requireArg } from '../helpers.js';
6
+ import * as fs from 'fs';
7
+ export const EXPOSES = [
8
+ 'POST /task/orgs/{org_id}/tasks',
9
+ 'GET /task/orgs/{org_id}/tasks',
10
+ 'GET /task/orgs/{org_id}/tasks/{id}',
11
+ 'DELETE /task/orgs/{org_id}/tasks/{id}',
12
+ 'GET /task/orgs/{org_id}/tasks/{id}/body',
13
+ 'POST /task/orgs/{org_id}/tasks/{id}/claim',
14
+ 'POST /task/orgs/{org_id}/tasks/{id}/extend',
15
+ 'POST /task/orgs/{org_id}/tasks/{id}/fail',
16
+ 'POST /task/orgs/{org_id}/tasks/{id}/resolve',
17
+ ];
18
+ export const SCHEMA = {
19
+ body: 'string',
20
+ importance: 'string',
21
+ due: 'string',
22
+ assignee: 'string',
23
+ tag: 'string',
24
+ 'depends-on': 'string',
25
+ 'dedup-key': 'string',
26
+ 'resolve-on': 'string',
27
+ source: 'string',
28
+ status: 'string',
29
+ limit: 'number',
30
+ lease: 'number',
31
+ worker: 'string',
32
+ reason: 'string',
33
+ };
34
+ // The backend's fixed importance enum — validated client-side so a typo
35
+ // fails before the network call.
36
+ export const TASK_IMPORTANCE = ['low', 'normal', 'high', 'critical'];
37
+ // Parses --resolve-on "<event_type>[:<field>=<value>]" into a matcher.
38
+ // Returns the parsed object, or a string error message.
39
+ export function _parseResolveOn(raw) {
40
+ const trimmed = raw.trim();
41
+ if (trimmed === '')
42
+ return '--resolve-on cannot be empty.';
43
+ const colon = trimmed.indexOf(':');
44
+ if (colon === -1)
45
+ return { event_type: trimmed };
46
+ const eventType = trimmed.slice(0, colon);
47
+ const rest = trimmed.slice(colon + 1);
48
+ if (eventType === '')
49
+ return '--resolve-on must start with an event type, e.g. payment.succeeded:amount=100';
50
+ const eq = rest.indexOf('=');
51
+ if (eq === -1) {
52
+ return `--resolve-on field match must look like field=value — got "${rest}".`;
53
+ }
54
+ return { event_type: eventType, field: rest.slice(0, eq), value: rest.slice(eq + 1) };
55
+ }
56
+ // Parses a comma-separated --tag / --depends-on flag into a clean array.
57
+ export function _splitList(raw) {
58
+ if (typeof raw !== 'string' || raw.trim() === '')
59
+ return undefined;
60
+ return raw.split(',').map(s => s.trim()).filter(Boolean);
61
+ }
62
+ // --body accepts inline text or @path to read a Markdown file.
63
+ function resolveBody(raw) {
64
+ if (raw.startsWith('@')) {
65
+ const path = raw.slice(1);
66
+ try {
67
+ return fs.readFileSync(path, 'utf-8');
68
+ }
69
+ catch (e) {
70
+ error(`Could not read --body file "${path}": ${e?.message ?? e}`);
71
+ }
72
+ }
73
+ return raw;
74
+ }
75
+ // ── Create / list ────────────────────────────────────────────────────────────
76
+ export async function create(description, flags) {
77
+ const config = requireConfig();
78
+ const orgId = requireOrg(flags, config, 'myapi task create "<description>" [--org <id>]');
79
+ requireArg(description, 'description', 'myapi task create "<description>"');
80
+ let resolveOn;
81
+ if (typeof flags['resolve-on'] === 'string') {
82
+ const parsed = _parseResolveOn(flags['resolve-on']);
83
+ if (typeof parsed === 'string')
84
+ error(parsed);
85
+ resolveOn = parsed;
86
+ }
87
+ let body;
88
+ if (typeof flags.body === 'string')
89
+ body = resolveBody(flags.body);
90
+ else if (flags.body === true)
91
+ error('--body needs a value: inline Markdown or @path-to-file.');
92
+ let importance;
93
+ if (typeof flags.importance === 'string') {
94
+ if (!TASK_IMPORTANCE.includes(flags.importance)) {
95
+ error(`Invalid --importance "${flags.importance}". Must be one of: ${TASK_IMPORTANCE.join(', ')}.`);
96
+ }
97
+ importance = flags.importance;
98
+ }
99
+ const t = await sdkTask.createTask(config.api_key, orgId, {
100
+ description,
101
+ body,
102
+ importance,
103
+ dueAt: typeof flags.due === 'string' ? flags.due : undefined,
104
+ assignee: typeof flags.assignee === 'string' ? flags.assignee : undefined,
105
+ tags: _splitList(flags.tag),
106
+ dependsOn: _splitList(flags['depends-on']),
107
+ dedupKey: typeof flags['dedup-key'] === 'string' ? flags['dedup-key'] : undefined,
108
+ resolveOn,
109
+ source: typeof flags.source === 'string' ? flags.source : undefined,
110
+ });
111
+ if (flags.json) {
112
+ printJson(t);
113
+ return;
114
+ }
115
+ success(`Task created: ${t.id}`);
116
+ info(`Status: ${t.status}`);
117
+ if (t.assignee)
118
+ info(`Assignee: ${t.assignee} (magic link emailed)`);
119
+ }
120
+ export async function list(flags) {
121
+ const config = requireConfig();
122
+ const orgId = requireOrg(flags, config, 'myapi task list [--org <id>]');
123
+ const res = await sdkTask.listTasks(config.api_key, orgId, {
124
+ status: typeof flags.status === 'string' ? flags.status : undefined,
125
+ tag: typeof flags.tag === 'string' ? flags.tag : undefined,
126
+ importance: typeof flags.importance === 'string' ? flags.importance : undefined,
127
+ assignee: typeof flags.assignee === 'string' ? flags.assignee : undefined,
128
+ source: typeof flags.source === 'string' ? flags.source : undefined,
129
+ limit: typeof flags.limit === 'number' ? flags.limit : undefined,
130
+ });
131
+ if (flags.json) {
132
+ printJson(res);
133
+ return;
134
+ }
135
+ printTable(res.tasks.map(t => ({
136
+ id: t.id,
137
+ score: t.score,
138
+ description: t.description,
139
+ })), { flags, empty: 'No open tasks.' });
140
+ info('');
141
+ info(`Showing ${res.meta.shown} of ${res.meta.total_open} open task(s).`);
142
+ }
143
+ // ── Read ─────────────────────────────────────────────────────────────────────
144
+ export async function get(id, flags) {
145
+ const config = requireConfig();
146
+ const orgId = requireOrg(flags, config, 'myapi task get <id> [--body] [--org <id>]');
147
+ requireArg(id, 'id', 'myapi task get <id>');
148
+ const t = await sdkTask.getTask(config.api_key, orgId, id);
149
+ // --body opts in to the separate body tier — never fetched implicitly.
150
+ const wantBody = flags.body === true || typeof flags.body === 'string';
151
+ const body = wantBody ? await sdkTask.getTaskBody(config.api_key, orgId, id) : undefined;
152
+ if (flags.json) {
153
+ printJson(wantBody ? { ...t, body } : t);
154
+ return;
155
+ }
156
+ info(`Task: ${t.id}`);
157
+ info(`Status: ${t.status}`);
158
+ info(`Description: ${t.description}`);
159
+ if (t.importance)
160
+ info(`Importance: ${t.importance}`);
161
+ if (t.due_at)
162
+ info(`Due: ${formatDate(t.due_at)}`);
163
+ if (t.assignee)
164
+ info(`Assignee: ${t.assignee}`);
165
+ if (t.tags?.length)
166
+ info(`Tags: ${t.tags.join(', ')}`);
167
+ if (t.depends_on?.length)
168
+ info(`Depends on: ${t.depends_on.join(', ')}`);
169
+ if (t.claimed_by)
170
+ info(`Claimed by: ${t.claimed_by}`);
171
+ if (t.lease_expires_at)
172
+ info(`Lease until: ${formatDate(t.lease_expires_at)}`);
173
+ if (wantBody) {
174
+ info('');
175
+ info('── body ──');
176
+ info(body ?? '');
177
+ }
178
+ }
179
+ // ── Lifecycle ────────────────────────────────────────────────────────────────
180
+ export async function claim(id, flags) {
181
+ const config = requireConfig();
182
+ const orgId = requireOrg(flags, config, 'myapi task claim <id> [--lease <seconds>] [--worker <name>] [--org <id>]');
183
+ requireArg(id, 'id', 'myapi task claim <id>');
184
+ const t = await sdkTask.claimTask(config.api_key, orgId, id, {
185
+ leaseSeconds: typeof flags.lease === 'number' ? flags.lease : undefined,
186
+ worker: typeof flags.worker === 'string' ? flags.worker : undefined,
187
+ });
188
+ if (flags.json) {
189
+ printJson(t);
190
+ return;
191
+ }
192
+ success(`Claimed task ${t.id}`);
193
+ if (t.lease_expires_at)
194
+ info(`Lease expires: ${formatDate(t.lease_expires_at)} — heartbeat with: myapi task extend ${t.id}`);
195
+ }
196
+ export async function extend(id, flags) {
197
+ const config = requireConfig();
198
+ const orgId = requireOrg(flags, config, 'myapi task extend <id> [--lease <seconds>] [--org <id>]');
199
+ requireArg(id, 'id', 'myapi task extend <id>');
200
+ const t = await sdkTask.extendTask(config.api_key, orgId, id, {
201
+ leaseSeconds: typeof flags.lease === 'number' ? flags.lease : undefined,
202
+ });
203
+ if (flags.json) {
204
+ printJson(t);
205
+ return;
206
+ }
207
+ success(`Extended lease on task ${t.id}`);
208
+ if (t.lease_expires_at)
209
+ info(`Lease expires: ${formatDate(t.lease_expires_at)}`);
210
+ }
211
+ export async function resolve(id, flags) {
212
+ const config = requireConfig();
213
+ const orgId = requireOrg(flags, config, 'myapi task resolve <id> [--org <id>]');
214
+ requireArg(id, 'id', 'myapi task resolve <id>');
215
+ await sdkTask.resolveTask(config.api_key, orgId, id);
216
+ success(`Resolved task ${id}`);
217
+ }
218
+ export async function fail(id, flags) {
219
+ const config = requireConfig();
220
+ const orgId = requireOrg(flags, config, 'myapi task fail <id> --reason "<why>" [--org <id>]');
221
+ requireArg(id, 'id', 'myapi task fail <id> --reason "<why>"');
222
+ const reason = flags.reason;
223
+ if (typeof reason !== 'string' || reason.trim() === '') {
224
+ error('Missing --reason.\nUsage: myapi task fail <id> --reason "<why>"');
225
+ }
226
+ await sdkTask.failTask(config.api_key, orgId, id, reason);
227
+ success(`Failed task ${id}`);
228
+ }
229
+ export async function cancel(id, flags) {
230
+ const config = requireConfig();
231
+ const orgId = requireOrg(flags, config, 'myapi task cancel <id> [--org <id>]');
232
+ requireArg(id, 'id', 'myapi task cancel <id>');
233
+ await sdkTask.cancelTask(config.api_key, orgId, id);
234
+ success(`Cancelled task ${id}`);
235
+ }
236
+ // ── Dispatcher ───────────────────────────────────────────────────────────────
237
+ const SUBCOMMAND_USAGE = {
238
+ 'create': 'myapi task create "<description>" [--body <md|@file>] [--importance <i>] [--due <rfc3339>] [--assignee <email>] [--tag <t,t>] [--depends-on <id,id>] [--dedup-key <k>] [--resolve-on <event[:field=value]>] [--source <s>] [--org <id>]',
239
+ 'list': 'myapi task list [--status <s>] [--tag <t>] [--importance <i>] [--assignee <email>] [--source <s>] [--limit <n>] [--org <id>] [--json]',
240
+ 'get': 'myapi task get <id> [--body] [--org <id>] [--json]\n\n--body additionally fetches the Markdown body tier (a separate read).',
241
+ 'claim': 'myapi task claim <id> [--lease <seconds>] [--worker <name>] [--org <id>]',
242
+ 'extend': 'myapi task extend <id> [--lease <seconds>] [--org <id>]',
243
+ 'resolve': 'myapi task resolve <id> [--org <id>]',
244
+ 'fail': 'myapi task fail <id> --reason "<why>" [--org <id>]',
245
+ 'cancel': 'myapi task cancel <id> [--org <id>]',
246
+ };
247
+ export async function run(subcommand, args, flags) {
248
+ if (!subcommand || (flags.help && !subcommand)) {
249
+ info(`Usage: myapi task <subcommand>
250
+
251
+ An agent-task queue — the agent-loop hot path. Tasks are ranked by score,
252
+ claimed under a lease, then resolved, failed, or cancelled.
253
+
254
+ create "<description>" File a task (--body, --importance, --assignee, --resolve-on, ...)
255
+ list Top open tasks, ranked (--status, --tag, --limit)
256
+ get <id> Show a task; --body also fetches the Markdown body tier
257
+ claim <id> Take an atomic lease (default 10 min)
258
+ extend <id> Heartbeat a live claim
259
+ resolve <id> Resolve a task (terminal) — unblocks dependents
260
+ fail <id> Fail a task (terminal) — requires --reason
261
+ cancel <id> Cancel a task (terminal, distinct from fail)
262
+
263
+ The agent loop is: list → get <id> --body → claim → resolve.
264
+ For durable machine work (retried HTTP jobs), see: myapi queue --help
265
+
266
+ All commands accept --org <id> (or set default: myapi config set-org <id>).`);
267
+ return;
268
+ }
269
+ if (flags.help) {
270
+ const usage = SUBCOMMAND_USAGE[subcommand];
271
+ if (usage)
272
+ info(`Usage: ${usage}`);
273
+ else
274
+ info(`Unknown subcommand: ${subcommand}. Run "myapi task --help" for the list.`);
275
+ return;
276
+ }
277
+ switch (subcommand) {
278
+ case 'create': return create(args[0], flags);
279
+ case 'list': return list(flags);
280
+ case 'get': return get(args[0], flags);
281
+ case 'claim': return claim(args[0], flags);
282
+ case 'extend': return extend(args[0], flags);
283
+ case 'resolve': return resolve(args[0], flags);
284
+ case 'fail': return fail(args[0], flags);
285
+ case 'cancel': return cancel(args[0], flags);
286
+ default: error(`Unknown subcommand: ${subcommand}. Run "myapi task --help" for a list of valid subcommands.`);
287
+ }
288
+ }
@@ -120,6 +120,33 @@ describe('_validateSteps — http_request / http (2026-05-15)', () => {
120
120
  .toMatch(/"headers" value for "X-Token" must be a string/);
121
121
  });
122
122
  });
123
+ describe('_validateSteps — enqueue_job / enqueue (2026-05-19)', () => {
124
+ const VALID_ENQUEUE = { type: 'enqueue_job', queue: 'thumbnails' };
125
+ it('accepts the canonical type name', () => {
126
+ expect(_validateSteps([VALID_ENQUEUE])).toBeNull();
127
+ });
128
+ it('accepts the `enqueue` alias', () => {
129
+ expect(_validateSteps([{ ...VALID_ENQUEUE, type: 'enqueue' }])).toBeNull();
130
+ });
131
+ it('accepts a string payload with templating', () => {
132
+ expect(_validateSteps([{ ...VALID_ENQUEUE, payload: '{"email":"{{ payload.email }}"}' }])).toBeNull();
133
+ });
134
+ it('accepts dedup_key and delay_seconds', () => {
135
+ expect(_validateSteps([{ ...VALID_ENQUEUE, dedup_key: 'k1', delay_seconds: 30 }])).toBeNull();
136
+ });
137
+ it('rejects when queue is missing', () => {
138
+ expect(_validateSteps([{ type: 'enqueue_job' }]))
139
+ .toMatch(/missing required field "queue"/);
140
+ });
141
+ it('rejects a non-string payload', () => {
142
+ expect(_validateSteps([{ ...VALID_ENQUEUE, payload: { not: 'a string' } }]))
143
+ .toMatch(/"payload" must be a string/);
144
+ });
145
+ it('rejects a non-number delay_seconds', () => {
146
+ expect(_validateSteps([{ ...VALID_ENQUEUE, delay_seconds: '30' }]))
147
+ .toMatch(/"delay_seconds" must be a number/);
148
+ });
149
+ });
123
150
  describe('_validateSteps — mixed-type pipelines', () => {
124
151
  it('accepts an http_request after a send_email (pipeline shape)', () => {
125
152
  expect(_validateSteps([
@@ -26,7 +26,9 @@ export const SCHEMA = {
26
26
  //
27
27
  // 2026-05-15: backend added `http_request` (alias `http`). See
28
28
  // myapi-hq/internal/routes/workflow/execute.go.
29
- const SUPPORTED_STEP_TYPES = ['send_email', 'email', 'slack_message', 'slack', 'http_request', 'http'];
29
+ // 2026-05-19: `enqueue_job` (alias `enqueue`) added hands durable work to
30
+ // my-queue-api. Backend step pending; see the orchestration cross-repo prompt.
31
+ const SUPPORTED_STEP_TYPES = ['send_email', 'email', 'slack_message', 'slack', 'http_request', 'http', 'enqueue_job', 'enqueue'];
30
32
  const HTTP_METHODS = new Set(['GET', 'POST', 'PATCH', 'PUT', 'DELETE']);
31
33
  const SLACK_HOOK_RE = /^https:\/\/hooks\.slack\.com\/services\/[A-Za-z0-9_-]+\/[A-Za-z0-9_-]+\/[A-Za-z0-9_-]+/;
32
34
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
@@ -110,6 +112,20 @@ export function _validateSteps(steps) {
110
112
  }
111
113
  }
112
114
  }
115
+ if (s.type === 'enqueue_job' || s.type === 'enqueue') {
116
+ if (!s.queue || typeof s.queue !== 'string') {
117
+ return `${where} (${s.type}): missing required field "queue" (the target queue name).`;
118
+ }
119
+ if (s.payload !== undefined && typeof s.payload !== 'string') {
120
+ return `${where} (${s.type}): "payload" must be a string (template substitutions supported).`;
121
+ }
122
+ if (s.dedup_key !== undefined && typeof s.dedup_key !== 'string') {
123
+ return `${where} (${s.type}): "dedup_key" must be a string.`;
124
+ }
125
+ if (s.delay_seconds !== undefined && typeof s.delay_seconds !== 'number') {
126
+ return `${where} (${s.type}): "delay_seconds" must be a number.`;
127
+ }
128
+ }
113
129
  }
114
130
  return null;
115
131
  }
@@ -28,8 +28,8 @@ const BLOCK_END = `# end ${PROGRAM} completion`;
28
28
  export const COMMANDS = [
29
29
  'audience', 'auth', 'billing', 'company', 'completion', 'config', 'container',
30
30
  'crm', 'database', 'domain', 'email', 'fn', 'funnel', 'git', 'help', 'image',
31
- 'install-skills', 'keys', 'llm', 'org', 'payments', 'people', 'pixel',
32
- 'setup', 'status', 'storage', 'update', 'url', 'webhook', 'whoami',
31
+ 'install-skills', 'keys', 'llm', 'org', 'payments', 'people', 'pixel', 'queue',
32
+ 'setup', 'status', 'storage', 'task', 'update', 'url', 'webhook', 'whoami',
33
33
  'workflow',
34
34
  ];
35
35
  // command → subcommands, for `myapi <command> <TAB>`. Mirrors each
@@ -57,8 +57,10 @@ export const SUBCOMMANDS = {
57
57
  config: ['view', 'set-org', 'set-funnel', 'set-domain'],
58
58
  fn: ['create', 'deploy', 'env', 'runs', 'list', 'get', 'delete'],
59
59
  payments: ['connect', 'status', 'charge', 'list', 'get', 'refund'],
60
- container: ['create', 'deploy', 'list', 'get', 'logs', 'delete'],
60
+ container: ['create', 'deploy', 'list', 'get', 'logs', 'domain', 'delete'],
61
61
  git: ['create', 'list', 'get', 'delete', 'refs', 'log', 'show', 'tree', 'blob', 'diff', 'commit', 'create-branch', 'delete-branch', 'tag', 'merge', 'repack'],
62
+ queue: ['create', 'list', 'get', 'enqueue', 'jobs', 'job'],
63
+ task: ['create', 'list', 'get', 'claim', 'extend', 'resolve', 'fail', 'cancel'],
62
64
  completion: ['install', 'uninstall'],
63
65
  };
64
66
  // ── Completion request handling ──────────────────────────────────────────────
@@ -41,6 +41,8 @@ const COMMAND_MODULES = [
41
41
  './commands/payments.js',
42
42
  './commands/container.js',
43
43
  './commands/git.js',
44
+ './commands/queue.js',
45
+ './commands/task.js',
44
46
  ];
45
47
  const ENDPOINT_PATTERN = /^(GET|POST|PATCH|PUT|DELETE) \/[A-Za-z0-9_\-./{}]*$/;
46
48
  describe('every CLI command exports a typed EXPOSES array (S-101)', () => {
package/dist/index.js CHANGED
@@ -33,6 +33,8 @@ import * as fnCmd from './commands/fn.js';
33
33
  import * as paymentsCmd from './commands/payments.js';
34
34
  import * as containerCmd from './commands/container.js';
35
35
  import * as gitCmd from './commands/git.js';
36
+ import * as queueCmd from './commands/queue.js';
37
+ import * as taskCmd from './commands/task.js';
36
38
  // Each command file declares the value flags it understands. We union them
37
39
  // into a single schema for the upfront parse, so adding a new value flag in
38
40
  // one command means editing one file (its SCHEMA), not a global allowlist.
@@ -62,6 +64,8 @@ const COMBINED_SCHEMA = {
62
64
  ...paymentsCmd.SCHEMA,
63
65
  ...containerCmd.SCHEMA,
64
66
  ...gitCmd.SCHEMA,
67
+ ...queueCmd.SCHEMA,
68
+ ...taskCmd.SCHEMA,
65
69
  // Top-level flags
66
70
  version: 'boolean',
67
71
  V: 'boolean',
@@ -224,6 +228,12 @@ async function main() {
224
228
  case 'git':
225
229
  await gitCmd.run(subcommand, restArgs, flags);
226
230
  break;
231
+ case 'queue':
232
+ await queueCmd.run(subcommand, restArgs, flags);
233
+ break;
234
+ case 'task':
235
+ await taskCmd.run(subcommand, restArgs, flags);
236
+ break;
227
237
  // Convenience aliases
228
238
  case 'setup':
229
239
  await setupCmd.setup(flags);
@@ -348,6 +358,8 @@ const HELP_TARGETS = {
348
358
  payments: f => paymentsCmd.run(undefined, [], f),
349
359
  container: f => containerCmd.run(undefined, [], f),
350
360
  git: f => gitCmd.run(undefined, [], f),
361
+ queue: f => queueCmd.run(undefined, [], f),
362
+ task: f => taskCmd.run(undefined, [], f),
351
363
  org: f => orgCmd.run(undefined, [], f),
352
364
  billing: f => billingCmd.run(undefined, [], f),
353
365
  keys: f => keysCmd.run(undefined, [], f),
@@ -392,6 +404,8 @@ Commands:
392
404
  fn Create and deploy functions on the edge runtime
393
405
  container Run containers — services, workers, and scheduled jobs
394
406
  git Hosted git repositories — repos, commits, branches, history
407
+ queue Durable job queue — enqueue work, retried against an HTTP consumer
408
+ task Agent-task queue — file, claim, and resolve units of work
395
409
  payments Take payments with Stripe Checkout (connect, charge, refund)
396
410
  webhook Manage inbound webhook endpoints and inspect deliveries
397
411
  email Manage mailboxes, send/read email, templates, and campaigns
@@ -126,7 +126,7 @@ describe('container.getContainerLogs', () => {
126
126
  });
127
127
  });
128
128
  describe('container.EXPOSES', () => {
129
- it('covers the 6 container endpoints', () => {
129
+ it('covers the 8 container endpoints', () => {
130
130
  expect(container.EXPOSES).toEqual([
131
131
  'POST /container/orgs/{org_id}/containers',
132
132
  'GET /container/orgs/{org_id}/containers',
@@ -134,6 +134,40 @@ describe('container.EXPOSES', () => {
134
134
  'DELETE /container/orgs/{org_id}/containers/{id}',
135
135
  'POST /container/orgs/{org_id}/containers/{id}/deploy',
136
136
  'GET /container/orgs/{org_id}/containers/{id}/logs',
137
+ 'POST /container/orgs/{org_id}/containers/{id}/domain',
138
+ 'DELETE /container/orgs/{org_id}/containers/{id}/domain',
137
139
  ]);
138
140
  });
139
141
  });
142
+ describe('container custom domain', () => {
143
+ it('bindDomain POSTs {domain} and returns the binding', async () => {
144
+ fetchMock.mockResolvedValueOnce(ok({
145
+ container_id: C_ID, custom_domain: 'app.synthesisdaily.com',
146
+ origin: 'svc-abc.run.app', status: 'active',
147
+ }));
148
+ const b = await container.bindDomain(API_KEY, ORG_ID, C_ID, 'app.synthesisdaily.com');
149
+ expect(b.status).toBe('active');
150
+ expect(b.origin).toBe('svc-abc.run.app');
151
+ const [url, init] = fetchMock.mock.calls[0];
152
+ expect(url).toBe(`https://api.myapihq.com/container/orgs/${ORG_ID}/containers/${C_ID}/domain`);
153
+ expect(init.method).toBe('POST');
154
+ expect(JSON.parse(init.body)).toEqual({ domain: 'app.synthesisdaily.com' });
155
+ });
156
+ it('bindDomain surfaces 422 when the container is not deployed', async () => {
157
+ fetchMock.mockResolvedValueOnce(fail('container_not_deployed', 'container not deployed', 422));
158
+ await expect(container.bindDomain(API_KEY, ORG_ID, C_ID, 'app.x.com'))
159
+ .rejects.toMatchObject({ status: 422 });
160
+ });
161
+ it('bindDomain surfaces 409 when a domain is already bound', async () => {
162
+ fetchMock.mockResolvedValueOnce(fail('domain_conflict', 'domain already bound', 409));
163
+ await expect(container.bindDomain(API_KEY, ORG_ID, C_ID, 'app.x.com'))
164
+ .rejects.toMatchObject({ status: 409 });
165
+ });
166
+ it('unbindDomain DELETEs the domain endpoint', async () => {
167
+ fetchMock.mockResolvedValueOnce(new Response(null, { status: 204 }));
168
+ await container.unbindDomain(API_KEY, ORG_ID, C_ID);
169
+ const [url, init] = fetchMock.mock.calls[0];
170
+ expect(url).toBe(`https://api.myapihq.com/container/orgs/${ORG_ID}/containers/${C_ID}/domain`);
171
+ expect(init.method).toBe('DELETE');
172
+ });
173
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,48 @@
1
+ // SDK-level unit tests for mailbox forwarding —
2
+ // email.setForwarding + email.deleteForwarding. Mocks fetch — no network.
3
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
4
+ import { email } from '@myapihq/sdk';
5
+ const API_KEY = 'myapi_test_abc';
6
+ let fetchMock;
7
+ function ok(data, status = 200) {
8
+ return new Response(JSON.stringify({ success: true, data, meta: {} }), {
9
+ status, headers: { 'content-type': 'application/json' },
10
+ });
11
+ }
12
+ function fail(code, status) {
13
+ return new Response(JSON.stringify({ success: false, error: { code, message: code }, meta: {} }), {
14
+ status, headers: { 'content-type': 'application/json' },
15
+ });
16
+ }
17
+ beforeEach(() => { fetchMock = vi.fn(); globalThis.fetch = fetchMock; });
18
+ afterEach(() => { vi.restoreAllMocks(); });
19
+ describe('email.setForwarding', () => {
20
+ it('PUTs {forward_to} to the mailbox forwarding endpoint', async () => {
21
+ fetchMock.mockResolvedValueOnce(ok({ address: 'a@x.com', forward_to: 'b@y.com' }));
22
+ const res = await email.setForwarding(API_KEY, 'a@x.com', 'b@y.com');
23
+ const [url, init] = fetchMock.mock.calls[0];
24
+ expect(url).toContain('/email/mailboxes/a%40x.com/forwarding');
25
+ expect(init.method).toBe('PUT');
26
+ expect(JSON.parse(init.body)).toEqual({ forward_to: 'b@y.com' });
27
+ expect(res.forward_to).toBe('b@y.com');
28
+ });
29
+ it('surfaces FORWARD_LOOP (400)', async () => {
30
+ fetchMock.mockResolvedValueOnce(fail('FORWARD_LOOP', 400));
31
+ await expect(email.setForwarding(API_KEY, 'a@x.com', 'a@x.com'))
32
+ .rejects.toMatchObject({ code: 'FORWARD_LOOP', status: 400 });
33
+ });
34
+ });
35
+ describe('email.deleteForwarding', () => {
36
+ it('DELETEs the forwarding endpoint and resolves on 204', async () => {
37
+ fetchMock.mockResolvedValueOnce(new Response(null, { status: 204 }));
38
+ await email.deleteForwarding(API_KEY, 'a@x.com');
39
+ const [url, init] = fetchMock.mock.calls[0];
40
+ expect(url).toContain('/email/mailboxes/a%40x.com/forwarding');
41
+ expect(init.method).toBe('DELETE');
42
+ });
43
+ it('surfaces MAILBOX_NOT_OWNED (403)', async () => {
44
+ fetchMock.mockResolvedValueOnce(fail('MAILBOX_NOT_OWNED', 403));
45
+ await expect(email.deleteForwarding(API_KEY, 'a@x.com'))
46
+ .rejects.toMatchObject({ code: 'MAILBOX_NOT_OWNED', status: 403 });
47
+ });
48
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,86 @@
1
+ // SDK-level unit tests for the queue module. Verifies URL/body shape,
2
+ // envelope-unwrapping, query params, and error handling against
3
+ // myapi-hq/internal/routes/queue/. Mocks global fetch — no network.
4
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
5
+ import { queue } from '@myapihq/sdk';
6
+ const API_KEY = 'myapi_test_abc';
7
+ const ORG = '11111111-1111-4111-8111-111111111111';
8
+ let fetchMock;
9
+ function ok(data, status = 200) {
10
+ return new Response(JSON.stringify({ success: true, data, meta: {} }), {
11
+ status, headers: { 'content-type': 'application/json' },
12
+ });
13
+ }
14
+ function fail(code, status) {
15
+ return new Response(JSON.stringify({ success: false, error: { code, message: code }, meta: {} }), {
16
+ status, headers: { 'content-type': 'application/json' },
17
+ });
18
+ }
19
+ beforeEach(() => { fetchMock = vi.fn(); globalThis.fetch = fetchMock; });
20
+ afterEach(() => { vi.restoreAllMocks(); });
21
+ describe('queue queues', () => {
22
+ it('createQueue POSTs {name, consumer_url} and optional policy fields', async () => {
23
+ fetchMock.mockResolvedValueOnce(ok({ name: 'q', consumer_url: 'https://c', max_attempts: 5, max_concurrency: 5 }, 201));
24
+ await queue.createQueue(API_KEY, ORG, {
25
+ name: 'q', consumerUrl: 'https://c', maxAttempts: 3, maxConcurrency: 2,
26
+ });
27
+ const [url, init] = fetchMock.mock.calls[0];
28
+ expect(url).toContain(`/queue/orgs/${ORG}/queues`);
29
+ expect(init.method).toBe('POST');
30
+ expect(JSON.parse(init.body)).toEqual({
31
+ name: 'q', consumer_url: 'https://c', max_attempts: 3, max_concurrency: 2,
32
+ });
33
+ });
34
+ it('createQueue omits unset optional fields', async () => {
35
+ fetchMock.mockResolvedValueOnce(ok({ name: 'q', consumer_url: 'https://c', max_attempts: 5, max_concurrency: 5 }, 201));
36
+ await queue.createQueue(API_KEY, ORG, { name: 'q', consumerUrl: 'https://c' });
37
+ expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ name: 'q', consumer_url: 'https://c' });
38
+ });
39
+ it('listQueues unwraps the {queues:[...]} envelope to an array', async () => {
40
+ fetchMock.mockResolvedValueOnce(ok({ queues: [{ name: 'a' }, { name: 'b' }] }));
41
+ expect(await queue.listQueues(API_KEY, ORG)).toEqual([{ name: 'a' }, { name: 'b' }]);
42
+ });
43
+ it('getQueue GETs /queues/{name}', async () => {
44
+ fetchMock.mockResolvedValueOnce(ok({ name: 'q', consumer_url: 'https://c', max_attempts: 5, max_concurrency: 5 }));
45
+ const q = await queue.getQueue(API_KEY, ORG, 'q');
46
+ expect(q.max_attempts).toBe(5);
47
+ expect(fetchMock.mock.calls[0][0]).toContain(`/queue/orgs/${ORG}/queues/q`);
48
+ });
49
+ });
50
+ describe('queue jobs', () => {
51
+ it('enqueueJob POSTs payload/dedup_key/delay_seconds/depends_on', async () => {
52
+ fetchMock.mockResolvedValueOnce(ok({ id: 'j1', queue: 'q', status: 'pending', attempts: 0 }, 201));
53
+ await queue.enqueueJob(API_KEY, ORG, 'q', {
54
+ payload: { x: 1 }, dedupKey: 'k', delaySeconds: 30, dependsOn: ['j0'],
55
+ });
56
+ const [url, init] = fetchMock.mock.calls[0];
57
+ expect(url).toContain(`/queue/orgs/${ORG}/queues/q/jobs`);
58
+ expect(init.method).toBe('POST');
59
+ expect(JSON.parse(init.body)).toEqual({
60
+ payload: { x: 1 }, dedup_key: 'k', delay_seconds: 30, depends_on: ['j0'],
61
+ });
62
+ });
63
+ it('listJobs unwraps {jobs} and passes status/limit as query', async () => {
64
+ fetchMock.mockResolvedValueOnce(ok({ jobs: [{ id: 'j1', status: 'dead' }] }));
65
+ const jobs = await queue.listJobs(API_KEY, ORG, 'q', { status: 'dead', limit: 50 });
66
+ expect(jobs).toHaveLength(1);
67
+ const url = fetchMock.mock.calls[0][0];
68
+ expect(url).toMatch(/\/jobs\?/);
69
+ expect(url).toContain('status=dead');
70
+ expect(url).toContain('limit=50');
71
+ });
72
+ it('getJob GETs /jobs/{id} and surfaces 404', async () => {
73
+ fetchMock.mockResolvedValueOnce(ok({ id: 'j1', queue: 'q', status: 'succeeded', attempts: 1 }));
74
+ expect((await queue.getJob(API_KEY, ORG, 'j1')).status).toBe('succeeded');
75
+ expect(fetchMock.mock.calls[0][0]).toContain(`/queue/orgs/${ORG}/jobs/j1`);
76
+ fetchMock.mockResolvedValueOnce(fail('job not found', 404));
77
+ await expect(queue.getJob(API_KEY, ORG, 'gone')).rejects.toMatchObject({ status: 404 });
78
+ });
79
+ });
80
+ describe('queue.EXPOSES', () => {
81
+ it('covers all 6 queue endpoints', () => {
82
+ expect(queue.EXPOSES).toHaveLength(6);
83
+ expect(queue.EXPOSES).toContain('POST /queue/orgs/{org_id}/queues/{name}/jobs');
84
+ expect(queue.EXPOSES).toContain('GET /queue/orgs/{org_id}/jobs/{id}');
85
+ });
86
+ });
@@ -0,0 +1 @@
1
+ export {};