@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.
- package/dist/commands/container.d.ts +1 -0
- package/dist/commands/container.js +41 -0
- package/dist/commands/email/mailbox.js +28 -1
- package/dist/commands/queue-validation.test.d.ts +1 -0
- package/dist/commands/queue-validation.test.js +38 -0
- package/dist/commands/queue.d.ts +14 -0
- package/dist/commands/queue.js +215 -0
- package/dist/commands/task-validation.test.d.ts +1 -0
- package/dist/commands/task-validation.test.js +37 -0
- package/dist/commands/task.d.ts +18 -0
- package/dist/commands/task.js +288 -0
- package/dist/commands/workflow-validation.test.js +27 -0
- package/dist/commands/workflow.js +17 -1
- package/dist/completion.js +5 -3
- package/dist/exposes.test.js +2 -0
- package/dist/index.js +14 -0
- package/dist/sdk-container.test.js +35 -1
- package/dist/sdk-email-forwarding.test.d.ts +1 -0
- package/dist/sdk-email-forwarding.test.js +48 -0
- package/dist/sdk-queue.test.d.ts +1 -0
- package/dist/sdk-queue.test.js +86 -0
- package/dist/sdk-task.test.d.ts +1 -0
- package/dist/sdk-task.test.js +110 -0
- package/dist/skills/my-domain-api/SKILL.md +2 -0
- package/dist/skills/my-workflow-api/SKILL.md +10 -1
- package/package.json +2 -2
|
@@ -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
|
-
|
|
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
|
}
|
package/dist/completion.js
CHANGED
|
@@ -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 ──────────────────────────────────────────────
|
package/dist/exposes.test.js
CHANGED
|
@@ -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
|
|
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 {};
|