@myapihq/cli 1.2.8 → 1.3.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.
- package/dist/commands/email/index.js +1 -0
- package/dist/commands/email/mailbox.js +28 -1
- package/dist/commands/email/verify.d.ts +2 -2
- package/dist/commands/email/verify.js +85 -16
- package/dist/commands/git.d.ts +22 -0
- package/dist/commands/git.js +367 -0
- package/dist/commands/pixel.js +6 -3
- 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 +6 -3
- package/dist/exposes.test.js +3 -0
- package/dist/index.js +21 -0
- package/dist/sdk-email-forwarding.test.d.ts +1 -0
- package/dist/sdk-email-forwarding.test.js +48 -0
- package/dist/sdk-email-verify-bulk.test.d.ts +1 -0
- package/dist/sdk-email-verify-bulk.test.js +57 -0
- package/dist/sdk-git.test.d.ts +1 -0
- package/dist/sdk-git.test.js +115 -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-email-api/README.md +45 -0
- package/dist/skills/my-email-api/SKILL.md +80 -0
- package/dist/skills/my-email-api/claude/.claude-plugin/plugin.json +6 -0
- package/dist/skills/my-email-api/openapi/.gitkeep +0 -0
- package/dist/skills/my-workflow-api/SKILL.md +10 -1
- package/package.json +2 -2
|
@@ -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
|
@@ -27,9 +27,9 @@ const BLOCK_END = `# end ${PROGRAM} completion`;
|
|
|
27
27
|
// is missing here.
|
|
28
28
|
export const COMMANDS = [
|
|
29
29
|
'audience', 'auth', 'billing', 'company', 'completion', 'config', 'container',
|
|
30
|
-
'crm', 'database', 'domain', 'email', 'fn', 'funnel', 'help', 'image',
|
|
31
|
-
'install-skills', 'keys', 'llm', 'org', 'payments', 'people', 'pixel',
|
|
32
|
-
'setup', 'status', 'storage', 'update', 'url', 'webhook', 'whoami',
|
|
30
|
+
'crm', 'database', 'domain', 'email', 'fn', 'funnel', 'git', 'help', 'image',
|
|
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
|
|
@@ -58,6 +58,9 @@ export const SUBCOMMANDS = {
|
|
|
58
58
|
fn: ['create', 'deploy', 'env', 'runs', 'list', 'get', 'delete'],
|
|
59
59
|
payments: ['connect', 'status', 'charge', 'list', 'get', 'refund'],
|
|
60
60
|
container: ['create', 'deploy', 'list', 'get', 'logs', 'delete'],
|
|
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'],
|
|
61
64
|
completion: ['install', 'uninstall'],
|
|
62
65
|
};
|
|
63
66
|
// ── Completion request handling ──────────────────────────────────────────────
|
package/dist/exposes.test.js
CHANGED
|
@@ -40,6 +40,9 @@ const COMMAND_MODULES = [
|
|
|
40
40
|
'./commands/fn.js',
|
|
41
41
|
'./commands/payments.js',
|
|
42
42
|
'./commands/container.js',
|
|
43
|
+
'./commands/git.js',
|
|
44
|
+
'./commands/queue.js',
|
|
45
|
+
'./commands/task.js',
|
|
43
46
|
];
|
|
44
47
|
const ENDPOINT_PATTERN = /^(GET|POST|PATCH|PUT|DELETE) \/[A-Za-z0-9_\-./{}]*$/;
|
|
45
48
|
describe('every CLI command exports a typed EXPOSES array (S-101)', () => {
|
package/dist/index.js
CHANGED
|
@@ -32,6 +32,9 @@ import * as crmCmd from './commands/crm/index.js';
|
|
|
32
32
|
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
|
+
import * as gitCmd from './commands/git.js';
|
|
36
|
+
import * as queueCmd from './commands/queue.js';
|
|
37
|
+
import * as taskCmd from './commands/task.js';
|
|
35
38
|
// Each command file declares the value flags it understands. We union them
|
|
36
39
|
// into a single schema for the upfront parse, so adding a new value flag in
|
|
37
40
|
// one command means editing one file (its SCHEMA), not a global allowlist.
|
|
@@ -60,6 +63,9 @@ const COMBINED_SCHEMA = {
|
|
|
60
63
|
...fnCmd.SCHEMA,
|
|
61
64
|
...paymentsCmd.SCHEMA,
|
|
62
65
|
...containerCmd.SCHEMA,
|
|
66
|
+
...gitCmd.SCHEMA,
|
|
67
|
+
...queueCmd.SCHEMA,
|
|
68
|
+
...taskCmd.SCHEMA,
|
|
63
69
|
// Top-level flags
|
|
64
70
|
version: 'boolean',
|
|
65
71
|
V: 'boolean',
|
|
@@ -219,6 +225,15 @@ async function main() {
|
|
|
219
225
|
case 'container':
|
|
220
226
|
await containerCmd.run(subcommand, restArgs, flags);
|
|
221
227
|
break;
|
|
228
|
+
case 'git':
|
|
229
|
+
await gitCmd.run(subcommand, restArgs, flags);
|
|
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;
|
|
222
237
|
// Convenience aliases
|
|
223
238
|
case 'setup':
|
|
224
239
|
await setupCmd.setup(flags);
|
|
@@ -342,6 +357,9 @@ const HELP_TARGETS = {
|
|
|
342
357
|
fn: f => fnCmd.run(undefined, [], f),
|
|
343
358
|
payments: f => paymentsCmd.run(undefined, [], f),
|
|
344
359
|
container: f => containerCmd.run(undefined, [], f),
|
|
360
|
+
git: f => gitCmd.run(undefined, [], f),
|
|
361
|
+
queue: f => queueCmd.run(undefined, [], f),
|
|
362
|
+
task: f => taskCmd.run(undefined, [], f),
|
|
345
363
|
org: f => orgCmd.run(undefined, [], f),
|
|
346
364
|
billing: f => billingCmd.run(undefined, [], f),
|
|
347
365
|
keys: f => keysCmd.run(undefined, [], f),
|
|
@@ -385,6 +403,9 @@ Commands:
|
|
|
385
403
|
funnel Manage websites (publish pages, custom domains, funnels)
|
|
386
404
|
fn Create and deploy functions on the edge runtime
|
|
387
405
|
container Run containers — services, workers, and scheduled jobs
|
|
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
|
|
388
409
|
payments Take payments with Stripe Checkout (connect, charge, refund)
|
|
389
410
|
webhook Manage inbound webhook endpoints and inspect deliveries
|
|
390
411
|
email Manage mailboxes, send/read email, templates, and campaigns
|
|
@@ -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,57 @@
|
|
|
1
|
+
// SDK-level unit tests for async bulk email verification —
|
|
2
|
+
// email.verifyBulk + email.getVerifyJob. Mocks global 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
|
+
const ORG = '11111111-1111-4111-8111-111111111111';
|
|
7
|
+
let fetchMock;
|
|
8
|
+
function ok(data) {
|
|
9
|
+
return new Response(JSON.stringify({ success: true, data, meta: {} }), {
|
|
10
|
+
status: 200, headers: { 'content-type': 'application/json' },
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
function fail(code, status) {
|
|
14
|
+
return new Response(JSON.stringify({ success: false, error: { code, message: code }, meta: {} }), {
|
|
15
|
+
status, headers: { 'content-type': 'application/json' },
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
beforeEach(() => { fetchMock = vi.fn(); globalThis.fetch = fetchMock; });
|
|
19
|
+
afterEach(() => { vi.restoreAllMocks(); });
|
|
20
|
+
describe('email.verifyBulk', () => {
|
|
21
|
+
it('POSTs {emails} and returns the job', async () => {
|
|
22
|
+
fetchMock.mockResolvedValueOnce(ok({ job_id: 'j1', status: 'pending', total: 2, catch_all: true }));
|
|
23
|
+
const job = await email.verifyBulk(API_KEY, ORG, ['a@x.com', 'b@y.com']);
|
|
24
|
+
const [url, init] = fetchMock.mock.calls[0];
|
|
25
|
+
expect(url).toContain(`/email/orgs/${ORG}/verify-bulk`);
|
|
26
|
+
expect(init.method).toBe('POST');
|
|
27
|
+
expect(JSON.parse(init.body)).toEqual({ emails: ['a@x.com', 'b@y.com'] });
|
|
28
|
+
expect(job.job_id).toBe('j1');
|
|
29
|
+
});
|
|
30
|
+
it('passes catch_all through when set', async () => {
|
|
31
|
+
fetchMock.mockResolvedValueOnce(ok({ job_id: 'j1', status: 'pending', total: 1 }));
|
|
32
|
+
await email.verifyBulk(API_KEY, ORG, ['a@x.com'], false);
|
|
33
|
+
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ emails: ['a@x.com'], catch_all: false });
|
|
34
|
+
});
|
|
35
|
+
it('surfaces TOO_MANY_EMAILS (422)', async () => {
|
|
36
|
+
fetchMock.mockResolvedValueOnce(fail('TOO_MANY_EMAILS', 422));
|
|
37
|
+
await expect(email.verifyBulk(API_KEY, ORG, ['a@x.com']))
|
|
38
|
+
.rejects.toMatchObject({ code: 'TOO_MANY_EMAILS', status: 422 });
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
describe('email.getVerifyJob', () => {
|
|
42
|
+
it('GETs /verify-jobs/{id} and returns status + results', async () => {
|
|
43
|
+
fetchMock.mockResolvedValueOnce(ok({
|
|
44
|
+
job_id: 'j1', status: 'done', total: 1, completed: 1,
|
|
45
|
+
results: [{ email: 'a@x.com', verdict: 'deliverable', confidence: 0.9, source: 'smtp' }],
|
|
46
|
+
}));
|
|
47
|
+
const job = await email.getVerifyJob(API_KEY, ORG, 'j1');
|
|
48
|
+
expect(job.status).toBe('done');
|
|
49
|
+
expect(job.results?.[0].verdict).toBe('deliverable');
|
|
50
|
+
expect(fetchMock.mock.calls[0][0]).toContain(`/email/orgs/${ORG}/verify-jobs/j1`);
|
|
51
|
+
});
|
|
52
|
+
it('surfaces JOB_NOT_FOUND (404)', async () => {
|
|
53
|
+
fetchMock.mockResolvedValueOnce(fail('JOB_NOT_FOUND', 404));
|
|
54
|
+
await expect(email.getVerifyJob(API_KEY, ORG, 'nope'))
|
|
55
|
+
.rejects.toMatchObject({ code: 'JOB_NOT_FOUND', status: 404 });
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// SDK-level unit tests for the git module. Verifies URL/body shape,
|
|
2
|
+
// envelope-unwrapping, path encoding, and error handling against
|
|
3
|
+
// myapi-hq/internal/routes/git/. Mocks global fetch — no network.
|
|
4
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
5
|
+
import { git } 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('git repos', () => {
|
|
22
|
+
it('createRepo POSTs {name, default_branch?}', async () => {
|
|
23
|
+
fetchMock.mockResolvedValueOnce(ok({ name: 'app', default_branch: 'main' }, 201));
|
|
24
|
+
await git.createRepo(API_KEY, ORG, 'app', 'trunk');
|
|
25
|
+
const [url, init] = fetchMock.mock.calls[0];
|
|
26
|
+
expect(url).toContain(`/git/orgs/${ORG}/repos`);
|
|
27
|
+
expect(init.method).toBe('POST');
|
|
28
|
+
expect(JSON.parse(init.body)).toEqual({ name: 'app', default_branch: 'trunk' });
|
|
29
|
+
});
|
|
30
|
+
it('createRepo omits default_branch when not given', async () => {
|
|
31
|
+
fetchMock.mockResolvedValueOnce(ok({ name: 'app', default_branch: 'main' }, 201));
|
|
32
|
+
await git.createRepo(API_KEY, ORG, 'app');
|
|
33
|
+
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ name: 'app' });
|
|
34
|
+
});
|
|
35
|
+
it('listRepos unwraps the {repos:[...]} envelope to an array', async () => {
|
|
36
|
+
fetchMock.mockResolvedValueOnce(ok({ repos: [{ name: 'a' }, { name: 'b' }] }));
|
|
37
|
+
expect(await git.listRepos(API_KEY, ORG)).toEqual([{ name: 'a' }, { name: 'b' }]);
|
|
38
|
+
});
|
|
39
|
+
it('getRepo GETs /repos/{repo}', async () => {
|
|
40
|
+
fetchMock.mockResolvedValueOnce(ok({ name: 'app', default_branch: 'main', branches: 2, tags: 1 }));
|
|
41
|
+
const r = await git.getRepo(API_KEY, ORG, 'app');
|
|
42
|
+
expect(r.branches).toBe(2);
|
|
43
|
+
expect(fetchMock.mock.calls[0][0]).toContain(`/git/orgs/${ORG}/repos/app`);
|
|
44
|
+
});
|
|
45
|
+
it('deleteRepo DELETEs and surfaces 404', async () => {
|
|
46
|
+
fetchMock.mockResolvedValueOnce(new Response(null, { status: 204 }));
|
|
47
|
+
await git.deleteRepo(API_KEY, ORG, 'app');
|
|
48
|
+
expect(fetchMock.mock.calls[0][1].method).toBe('DELETE');
|
|
49
|
+
fetchMock.mockResolvedValueOnce(fail('repository not found', 404));
|
|
50
|
+
await expect(git.deleteRepo(API_KEY, ORG, 'gone')).rejects.toMatchObject({ status: 404 });
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
describe('git history & content', () => {
|
|
54
|
+
it('listCommits passes ref/limit as query and unwraps {commits}', async () => {
|
|
55
|
+
fetchMock.mockResolvedValueOnce(ok({ commits: [{ sha: 'abc', message: 'init' }] }));
|
|
56
|
+
const commits = await git.listCommits(API_KEY, ORG, 'app', { ref: 'dev', limit: 10 });
|
|
57
|
+
expect(commits).toHaveLength(1);
|
|
58
|
+
const url = fetchMock.mock.calls[0][0];
|
|
59
|
+
expect(url).toMatch(/\/commits\?/);
|
|
60
|
+
expect(url).toContain('ref=dev');
|
|
61
|
+
expect(url).toContain('limit=10');
|
|
62
|
+
});
|
|
63
|
+
it('getDiff requires base+head and unwraps {diff}', async () => {
|
|
64
|
+
fetchMock.mockResolvedValueOnce(ok({ diff: '--- a\n+++ b\n' }));
|
|
65
|
+
const d = await git.getDiff(API_KEY, ORG, 'app', 'main', 'dev');
|
|
66
|
+
expect(d).toBe('--- a\n+++ b\n');
|
|
67
|
+
const url = fetchMock.mock.calls[0][0];
|
|
68
|
+
expect(url).toContain('base=main');
|
|
69
|
+
expect(url).toContain('head=dev');
|
|
70
|
+
});
|
|
71
|
+
it('listTree unwraps {entries} and adds ?path when scoped', async () => {
|
|
72
|
+
fetchMock.mockResolvedValueOnce(ok({ entries: [{ name: 'a.js', type: 'file' }] }));
|
|
73
|
+
await git.listTree(API_KEY, ORG, 'app', 'main', 'src');
|
|
74
|
+
expect(fetchMock.mock.calls[0][0]).toMatch(/\/tree\/main\?path=src$/);
|
|
75
|
+
});
|
|
76
|
+
it('readBlob encodes path segments but keeps the slashes', async () => {
|
|
77
|
+
fetchMock.mockResolvedValueOnce(ok({ path: 'src/a b.js', size: 3, content_base64: 'eA==' }));
|
|
78
|
+
await git.readBlob(API_KEY, ORG, 'app', 'main', 'src/a b.js');
|
|
79
|
+
// slashes survive (catch-all route), spaces within a segment are encoded
|
|
80
|
+
expect(fetchMock.mock.calls[0][0]).toMatch(/\/blob\/main\/src\/a%20b\.js$/);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
describe('git writes', () => {
|
|
84
|
+
it('commit POSTs the full payload', async () => {
|
|
85
|
+
fetchMock.mockResolvedValueOnce(ok({ sha: 'def', tree: 't', branch: 'main' }, 201));
|
|
86
|
+
const res = await git.commit(API_KEY, ORG, 'app', {
|
|
87
|
+
branch: 'main', message: 'add', changes: [{ path: 'x', content: 'hi' }],
|
|
88
|
+
});
|
|
89
|
+
expect(res.sha).toBe('def');
|
|
90
|
+
expect(JSON.parse(fetchMock.mock.calls[0][1].body).changes).toHaveLength(1);
|
|
91
|
+
});
|
|
92
|
+
it('createBranch POSTs {name, from}', async () => {
|
|
93
|
+
fetchMock.mockResolvedValueOnce(ok({ name: 'feature' }, 201));
|
|
94
|
+
await git.createBranch(API_KEY, ORG, 'app', 'feature', 'main');
|
|
95
|
+
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ name: 'feature', from: 'main' });
|
|
96
|
+
});
|
|
97
|
+
it('merge POSTs {target, source} and surfaces NOT_FAST_FORWARD (422)', async () => {
|
|
98
|
+
fetchMock.mockResolvedValueOnce(ok({ sha: 'ff' }));
|
|
99
|
+
expect((await git.merge(API_KEY, ORG, 'app', 'main', 'dev')).sha).toBe('ff');
|
|
100
|
+
fetchMock.mockResolvedValueOnce(fail('NOT_FAST_FORWARD', 422));
|
|
101
|
+
await expect(git.merge(API_KEY, ORG, 'app', 'main', 'dev')).rejects.toMatchObject({ status: 422 });
|
|
102
|
+
});
|
|
103
|
+
it('repack POSTs and returns the pack counts', async () => {
|
|
104
|
+
fetchMock.mockResolvedValueOnce(ok({ packs_before: 5, packs_after: 1, objects: 200 }));
|
|
105
|
+
const r = await git.repack(API_KEY, ORG, 'app');
|
|
106
|
+
expect(r).toEqual({ packs_before: 5, packs_after: 1, objects: 200 });
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
describe('git.EXPOSES', () => {
|
|
110
|
+
it('covers all 16 git endpoints', () => {
|
|
111
|
+
expect(git.EXPOSES).toHaveLength(16);
|
|
112
|
+
expect(git.EXPOSES).toContain('POST /git/orgs/{org_id}/repos/{repo}/commits');
|
|
113
|
+
expect(git.EXPOSES).toContain('GET /git/orgs/{org_id}/repos/{repo}/blob/{ref}/{path}');
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -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 {};
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// SDK-level unit tests for the task module. Verifies URL/body shape,
|
|
2
|
+
// envelope-unwrapping, the list meta {shown,total_open}, the separate body
|
|
3
|
+
// tier, and lease modeling against myapi-hq/internal/routes/task/.
|
|
4
|
+
// Mocks global fetch — no network.
|
|
5
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
6
|
+
import { task } from '@myapihq/sdk';
|
|
7
|
+
const API_KEY = 'myapi_test_abc';
|
|
8
|
+
const ORG = '11111111-1111-4111-8111-111111111111';
|
|
9
|
+
let fetchMock;
|
|
10
|
+
function ok(data, status = 200) {
|
|
11
|
+
return new Response(JSON.stringify({ success: true, data, meta: {} }), {
|
|
12
|
+
status, headers: { 'content-type': 'application/json' },
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
function fail(code, status) {
|
|
16
|
+
return new Response(JSON.stringify({ success: false, error: { code, message: code }, meta: {} }), {
|
|
17
|
+
status, headers: { 'content-type': 'application/json' },
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
beforeEach(() => { fetchMock = vi.fn(); globalThis.fetch = fetchMock; });
|
|
21
|
+
afterEach(() => { vi.restoreAllMocks(); });
|
|
22
|
+
describe('task create / list', () => {
|
|
23
|
+
it('createTask POSTs {description} plus only the set optional fields', async () => {
|
|
24
|
+
fetchMock.mockResolvedValueOnce(ok({ id: 't1', description: 'do x', status: 'open' }, 201));
|
|
25
|
+
await task.createTask(API_KEY, ORG, {
|
|
26
|
+
description: 'do x', importance: 'high', dependsOn: ['t0'],
|
|
27
|
+
resolveOn: { event_type: 'payment.succeeded', field: 'id', value: '42' },
|
|
28
|
+
});
|
|
29
|
+
const [url, init] = fetchMock.mock.calls[0];
|
|
30
|
+
expect(url).toContain(`/task/orgs/${ORG}/tasks`);
|
|
31
|
+
expect(init.method).toBe('POST');
|
|
32
|
+
expect(JSON.parse(init.body)).toEqual({
|
|
33
|
+
description: 'do x', importance: 'high', depends_on: ['t0'],
|
|
34
|
+
resolve_on: { event_type: 'payment.succeeded', field: 'id', value: '42' },
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
it('listTasks reads tasks + data.meta {shown, total_open}', async () => {
|
|
38
|
+
fetchMock.mockResolvedValueOnce(ok({ tasks: [{ id: 't1', description: 'a', score: 9 }], meta: { shown: 1, total_open: 12 } }));
|
|
39
|
+
const res = await task.listTasks(API_KEY, ORG);
|
|
40
|
+
expect(res.tasks).toHaveLength(1);
|
|
41
|
+
expect(res.meta).toEqual({ shown: 1, total_open: 12 });
|
|
42
|
+
});
|
|
43
|
+
it('listTasks passes filter options as query params', async () => {
|
|
44
|
+
fetchMock.mockResolvedValueOnce(ok({ tasks: [], meta: { shown: 0, total_open: 0 } }));
|
|
45
|
+
await task.listTasks(API_KEY, ORG, { status: 'open', limit: 5 });
|
|
46
|
+
const url = fetchMock.mock.calls[0][0];
|
|
47
|
+
expect(url).toContain('status=open');
|
|
48
|
+
expect(url).toContain('limit=5');
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
describe('task get / body tier', () => {
|
|
52
|
+
it('getTask GETs /tasks/{id} and does NOT touch the body endpoint', async () => {
|
|
53
|
+
fetchMock.mockResolvedValueOnce(ok({ id: 't1', description: 'a', status: 'open' }));
|
|
54
|
+
await task.getTask(API_KEY, ORG, 't1');
|
|
55
|
+
const url = fetchMock.mock.calls[0][0];
|
|
56
|
+
expect(url).toContain(`/task/orgs/${ORG}/tasks/t1`);
|
|
57
|
+
expect(url).not.toMatch(/\/body$/);
|
|
58
|
+
});
|
|
59
|
+
it('getTaskBody fetches the /body tier and unwraps {task_id, body}', async () => {
|
|
60
|
+
fetchMock.mockResolvedValueOnce(ok({ task_id: 't1', body: '# context' }));
|
|
61
|
+
expect(await task.getTaskBody(API_KEY, ORG, 't1')).toBe('# context');
|
|
62
|
+
expect(fetchMock.mock.calls[0][0]).toMatch(/\/tasks\/t1\/body$/);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
describe('task lifecycle', () => {
|
|
66
|
+
it('claimTask POSTs {lease_seconds, worker} and returns the full task', async () => {
|
|
67
|
+
fetchMock.mockResolvedValueOnce(ok({
|
|
68
|
+
id: 't1', description: 'a', status: 'claimed',
|
|
69
|
+
lease_expires_at: '2026-05-19T10:10:00Z', claimed_by: 'agent-7',
|
|
70
|
+
}));
|
|
71
|
+
const t = await task.claimTask(API_KEY, ORG, 't1', { leaseSeconds: 600, worker: 'agent-7' });
|
|
72
|
+
expect(t.status).toBe('claimed');
|
|
73
|
+
expect(t.lease_expires_at).toBe('2026-05-19T10:10:00Z');
|
|
74
|
+
expect(t.claimed_by).toBe('agent-7');
|
|
75
|
+
const [url, init] = fetchMock.mock.calls[0];
|
|
76
|
+
expect(url).toMatch(/\/tasks\/t1\/claim$/);
|
|
77
|
+
expect(JSON.parse(init.body)).toEqual({ lease_seconds: 600, worker: 'agent-7' });
|
|
78
|
+
});
|
|
79
|
+
it('extendTask POSTs to /extend', async () => {
|
|
80
|
+
fetchMock.mockResolvedValueOnce(ok({ id: 't1', description: 'a', status: 'claimed' }));
|
|
81
|
+
await task.extendTask(API_KEY, ORG, 't1', { leaseSeconds: 300 });
|
|
82
|
+
expect(fetchMock.mock.calls[0][0]).toMatch(/\/tasks\/t1\/extend$/);
|
|
83
|
+
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ lease_seconds: 300 });
|
|
84
|
+
});
|
|
85
|
+
it('resolveTask POSTs to /resolve', async () => {
|
|
86
|
+
fetchMock.mockResolvedValueOnce(ok({ id: 't1', description: 'a', status: 'resolved' }));
|
|
87
|
+
await task.resolveTask(API_KEY, ORG, 't1');
|
|
88
|
+
expect(fetchMock.mock.calls[0][0]).toMatch(/\/tasks\/t1\/resolve$/);
|
|
89
|
+
});
|
|
90
|
+
it('failTask POSTs {reason} to /fail', async () => {
|
|
91
|
+
fetchMock.mockResolvedValueOnce(ok({ id: 't1', description: 'a', status: 'failed' }));
|
|
92
|
+
await task.failTask(API_KEY, ORG, 't1', 'blocked on legal');
|
|
93
|
+
expect(fetchMock.mock.calls[0][0]).toMatch(/\/tasks\/t1\/fail$/);
|
|
94
|
+
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ reason: 'blocked on legal' });
|
|
95
|
+
});
|
|
96
|
+
it('cancelTask DELETEs the task and surfaces 404', async () => {
|
|
97
|
+
fetchMock.mockResolvedValueOnce(new Response(null, { status: 204 }));
|
|
98
|
+
await task.cancelTask(API_KEY, ORG, 't1');
|
|
99
|
+
expect(fetchMock.mock.calls[0][1].method).toBe('DELETE');
|
|
100
|
+
fetchMock.mockResolvedValueOnce(fail('task not found', 404));
|
|
101
|
+
await expect(task.cancelTask(API_KEY, ORG, 'gone')).rejects.toMatchObject({ status: 404 });
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
describe('task.EXPOSES', () => {
|
|
105
|
+
it('covers all 9 task endpoints', () => {
|
|
106
|
+
expect(task.EXPOSES).toHaveLength(9);
|
|
107
|
+
expect(task.EXPOSES).toContain('GET /task/orgs/{org_id}/tasks/{id}/body');
|
|
108
|
+
expect(task.EXPOSES).toContain('POST /task/orgs/{org_id}/tasks/{id}/claim');
|
|
109
|
+
});
|
|
110
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
---
|
|
2
|
+
# my-email-api
|
|
3
|
+
|
|
4
|
+
Send transactional email and run drip campaigns from mailboxes on your own registered domains. Includes AI template generation, warmup, and inbox/outbox reading.
|
|
5
|
+
|
|
6
|
+
## What it does
|
|
7
|
+
|
|
8
|
+
- Create mailboxes on your registered domains
|
|
9
|
+
- Send transactional emails (one-shot or templated)
|
|
10
|
+
- Read inbox, outbox, sent history, and per-message status
|
|
11
|
+
- Generate HTML email templates with AI from a prompt
|
|
12
|
+
- Run paced drip campaigns against uploaded contact lists
|
|
13
|
+
- Manage IP/domain warmup for sender reputation
|
|
14
|
+
|
|
15
|
+
## Quickstart
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
# Create a mailbox + activate sending
|
|
19
|
+
myapi email mailbox create hello@yourdomain.com
|
|
20
|
+
myapi email mailbox activate-sending --address hello@yourdomain.com
|
|
21
|
+
|
|
22
|
+
# Send
|
|
23
|
+
myapi email message send \
|
|
24
|
+
--from hello@yourdomain.com \
|
|
25
|
+
--to recipient@example.com \
|
|
26
|
+
--subject "Hi" \
|
|
27
|
+
--body "Test"
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Authentication
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
export MYAPI_KEY=mak_...
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Requires:
|
|
37
|
+
- An `api_key` from **myapihq**
|
|
38
|
+
- A registered domain via **mydomainapi**, assigned to your org
|
|
39
|
+
- Default `org_id` (for templates/campaigns) — set with `myapi auth config set-org <id>`
|
|
40
|
+
|
|
41
|
+
## Documentation
|
|
42
|
+
|
|
43
|
+
Full command reference and flow diagrams: see `SKILL.md`.
|
|
44
|
+
|
|
45
|
+
Run `myapi email --help` for inline reference.
|