@sendoka/cli 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,88 @@
1
+ # sendoka CLI
2
+
3
+ Developer tools for the Sendoka API — send messages, tail messages as they are created, forward webhook deliveries locally, trigger synthetic events.
4
+
5
+ Five commands, all of them below. There is no key management, no domain management and no interactive login; use the [dashboard](https://www.sendoka.com/overview) or the REST API for the rest. Full reference: [docs/developer-tools/cli.md](https://www.sendoka.com/docs/guides/developer-tools/cli).
6
+
7
+ ## Install
8
+
9
+ Not published to npm yet, and Sendoka does not hold the `sendoka` name there:
10
+ a package installed under that name from the registry is not this one. From a
11
+ checkout of the repository:
12
+
13
+ ```bash
14
+ cd packages/cli
15
+ npm install && npm run build
16
+ npm link # puts `sendoka` on your PATH
17
+ ```
18
+
19
+ Requires Node 20+.
20
+
21
+ ## Configure
22
+
23
+ Every command authenticates with an API key.
24
+
25
+ ```bash
26
+ export SENDOKA_API_KEY=sok_test_...
27
+
28
+ # For `listen` only: the endpoint's whsec_ signing secret, so forwarded
29
+ # deliveries are re-signed. --secret works too.
30
+ export SENDOKA_WEBHOOK_SECRET=whsec_...
31
+
32
+ # Optional. Defaults to https://www.sendoka.com.
33
+ export SENDOKA_BASE_URL=https://www.sendoka.com
34
+ ```
35
+
36
+ The key needs the scope its endpoint needs: `send:email` / `send:sms` for the sends, `read:messages` for `logs tail`, `read:webhooks` for `listen`, `write:webhooks` for `events trigger`. A full-access key has all of them.
37
+
38
+ With a `sok_test_*` key, `listen` forwards only deliveries of test-mode events (test sends and `events trigger` fires): an endpoint also receives live traffic, and a test key cannot read it. Its list pages can come back empty with more to follow; `listen` starts below the first such page rather than forwarding older deliveries as new. To forward live deliveries, use a live key scoped to `read:webhooks` alone.
39
+
40
+ Every request counts against your org's `/api/v1` rate limit (60/min on Free), the same budget your production sends use. `listen` makes 12 requests a minute and `logs tail` 24 at the default 5-second interval; on a Free org raise `--interval`, or give the CLI a key with its own per-key limit. Both wait out a `429`'s `Retry-After` and resume without skipping anything.
41
+
42
+ `SENDOKA_SESSION_COOKIE` is no longer read. `listen` and `events trigger` used to send it to `/api/internal/*` under the cookie name NextAuth uses over plain http, which the https host never reads — both answered 401 against www.sendoka.com. They now use the public `/api/v1/webhooks` routes with the key.
43
+
44
+ ## Usage
45
+
46
+ ### Send
47
+
48
+ ```bash
49
+ sendoka send email \
50
+ --from hello@yourdomain.com \
51
+ --to user@example.com \
52
+ --subject "Hello" \
53
+ --html "<p>From the CLI</p>"
54
+
55
+ sendoka send sms --from +15551234567 --to +15559876543 --body "Test"
56
+ ```
57
+
58
+ `--to` is comma-separated for several recipients; the flag is not repeatable. `--template` + `--variables '{"name":"Mira"}'` render a stored template instead of an inline body. Attachments, tags, scheduling and `Idempotency-Key` are not exposed here — use an HTTP client for those.
59
+
60
+ ### Tail messages
61
+
62
+ ```bash
63
+ sendoka logs tail
64
+ sendoka logs tail --channel sms --interval 15
65
+ ```
66
+
67
+ Polls `GET /api/v1/emails` and `GET /api/v1/sms` by `created_after` and prints one line per **new** message, starting from now, for the key's environment. Each message is printed once, with its status when first seen; later transitions (`delivered`, `bounced`) are what webhooks are for. `--days` from the old aggregate version is ignored.
68
+
69
+ ### Forward webhook deliveries to a local URL
70
+
71
+ ```bash
72
+ sendoka listen --endpoint whk_xxx --forward-to http://localhost:3001/hooks
73
+ ```
74
+
75
+ Polls `GET /api/v1/webhooks/{id}/deliveries?include=payload` for that endpoint and re-POSTs each delivery created after the command started to `--forward-to` — body and headers as production sends them, **signed with the endpoint secret** (`--secret` or `SENDOKA_WEBHOOK_SECRET`), so the SDK's `verifyWebhookSignature` passes and your handler can keep verification on. Without a secret it warns and forwards unsigned. Avoids ngrok for local webhook dev.
76
+
77
+ ### Trigger a synthetic event
78
+
79
+ ```bash
80
+ sendoka events trigger message.bounced --endpoint whk_xxx
81
+ sendoka events trigger message.bounced --endpoint whk_xxx --data '{"channel":"sms"}'
82
+ ```
83
+
84
+ `POST /api/v1/webhooks/{id}/test-fire`: fires a properly-signed test delivery at one of your webhook endpoints. Payloads carry `data.test: true` and `"environment": "test"` so your handler can distinguish them; `--data` (a JSON object) is merged over the synthetic data but cannot change those markers or the synthetic `message_id`. `--event message.bounced` is accepted as an equivalent to the positional form. Combine with `listen` for a fully local loop.
85
+
86
+ ## Exit codes
87
+
88
+ `0` success, `1` the command threw (HTTP error, missing env var, bad `--variables` / `--data` JSON), `2` unknown command. There are no per-status codes — an HTTP error prints `HTTP <status>: <message> (<CODE>)` on stderr; branch on the code.
package/dist/cli.js ADDED
@@ -0,0 +1,65 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Sendoka CLI — minimal developer loop over the REST API.
4
+ *
5
+ * sendoka send email --from a --to b --subject hi --html "<p>…</p>"
6
+ * sendoka send sms --from +1 --to +1 --body hi
7
+ * sendoka logs tail (polls /api/v1/emails + /api/v1/sms by created_after)
8
+ * sendoka listen --endpoint whk_xxx --forward-to http://localhost:3001/hook
9
+ * (polls /api/v1/webhooks/:id/deliveries and forwards
10
+ * each new payload, re-signed with --secret)
11
+ * sendoka events trigger message.bounced --endpoint whk_xxx
12
+ * (hits /api/v1/webhooks/:id/test-fire)
13
+ *
14
+ * Auth: every command reads SENDOKA_API_KEY from the environment. There is no
15
+ * session-cookie path any more — see client.ts for why it never worked on https.
16
+ */
17
+ import { resolveCommand } from "./commands/index.js";
18
+ import { parseFlags, splitInvocation } from "./flags.js";
19
+ import { CLI_VERSION } from "./version.js";
20
+ async function main(argv) {
21
+ const [, , ...rest] = argv;
22
+ if (rest.length === 0 || rest[0] === "--help" || rest[0] === "-h") {
23
+ printUsage();
24
+ return;
25
+ }
26
+ if (rest[0] === "--version" || rest[0] === "-v") {
27
+ console.log(`sendoka ${CLI_VERSION}`);
28
+ return;
29
+ }
30
+ const { group, action, args } = splitInvocation(rest);
31
+ const cmd = resolveCommand(group, action);
32
+ if (!cmd) {
33
+ console.error(`Unknown command: ${group}${action ? " " + action : ""}`);
34
+ printUsage();
35
+ process.exit(2);
36
+ }
37
+ try {
38
+ await cmd(parseFlags(args));
39
+ }
40
+ catch (err) {
41
+ console.error(err.message);
42
+ process.exit(1);
43
+ }
44
+ }
45
+ function printUsage() {
46
+ console.log(`sendoka — developer CLI
47
+
48
+ Usage:
49
+ sendoka send email --from <addr> --to <addr> [--subject] [--html|--text] [--template]
50
+ sendoka send sms --from <num> --to <num> --body <text>
51
+ sendoka logs tail [--channel email|sms] [--interval 5]
52
+ sendoka listen --endpoint <whk_id> --forward-to <url> [--secret <whsec_...>] [--interval 5]
53
+ sendoka events trigger <event> --endpoint <whk_id> [--data <json>]
54
+
55
+ Env:
56
+ SENDOKA_API_KEY sok_live_* or sok_test_* — every command
57
+ SENDOKA_BASE_URL defaults to https://www.sendoka.com
58
+ SENDOKA_WEBHOOK_SECRET the endpoint's whsec_ secret, for \`listen\` (or --secret)
59
+
60
+ Run with --help on any subcommand for details.`);
61
+ }
62
+ main(process.argv).catch((err) => {
63
+ console.error(err);
64
+ process.exit(1);
65
+ });
package/dist/client.js ADDED
@@ -0,0 +1,70 @@
1
+ export function baseUrl() {
2
+ return (process.env.SENDOKA_BASE_URL ?? "https://www.sendoka.com").replace(/\/$/, "");
3
+ }
4
+ export function apiKey() {
5
+ const k = process.env.SENDOKA_API_KEY;
6
+ if (!k)
7
+ throw new Error("Set SENDOKA_API_KEY to an sok_live_* / sok_test_* key.");
8
+ return k;
9
+ }
10
+ /**
11
+ * A non-2xx answer from `/api/v1`. The message keeps the `HTTP <status>:
12
+ * <message> (<CODE>)` shape the CLI prints; `status` and `retryAfterSeconds`
13
+ * are there so a polling command can tell a 429 — back off, the rows are
14
+ * still there — from an answer that will not change on retry.
15
+ */
16
+ export class ApiError extends Error {
17
+ status;
18
+ code;
19
+ retryAfterSeconds;
20
+ constructor(message, status, code, retryAfterSeconds) {
21
+ super(message);
22
+ this.status = status;
23
+ this.code = code;
24
+ this.retryAfterSeconds = retryAfterSeconds;
25
+ this.name = "ApiError";
26
+ }
27
+ }
28
+ export function isRateLimited(err) {
29
+ return err instanceof ApiError && err.status === 429;
30
+ }
31
+ /** `Retry-After` in seconds, or null. Only the delta-seconds form is sent by /v1. */
32
+ function retryAfter(res) {
33
+ const raw = typeof res.headers?.get === "function" ? res.headers.get("retry-after") : null;
34
+ const n = raw === null ? NaN : Number(raw);
35
+ return Number.isFinite(n) && n >= 0 ? n : null;
36
+ }
37
+ /**
38
+ * Every command goes through here, with the API key. There used to be a second
39
+ * client for `/api/internal/*` that sent `Cookie: next-auth.session-token=…`,
40
+ * for `listen` and `events trigger` — and it could not authenticate against
41
+ * the default host. NextAuth v4 names the cookie
42
+ * `__Secure-next-auth.session-token` whenever it is served over https, so the
43
+ * unprefixed name the CLI sent was never read and both commands answered 401
44
+ * on www.sendoka.com. Both now use the public `/v1/webhooks` routes instead.
45
+ */
46
+ export async function callV1(method, path, body) {
47
+ const res = await fetch(`${baseUrl()}/api/v1${path}`, {
48
+ method,
49
+ headers: {
50
+ Authorization: `Bearer ${apiKey()}`,
51
+ "Content-Type": "application/json",
52
+ },
53
+ body: body ? JSON.stringify(body) : undefined,
54
+ });
55
+ const text = await res.text();
56
+ let parsed;
57
+ try {
58
+ parsed = text ? JSON.parse(text) : {};
59
+ }
60
+ catch {
61
+ parsed = text;
62
+ }
63
+ if (!res.ok) {
64
+ const error = parsed?.error;
65
+ const message = error?.message ?? text;
66
+ // The code is what a script branches on — the message is prose and moves.
67
+ throw new ApiError(`HTTP ${res.status}: ${message}${error?.code ? ` (${error.code})` : ""}`, res.status, error?.code ?? null, retryAfter(res));
68
+ }
69
+ return parsed;
70
+ }
@@ -0,0 +1,47 @@
1
+ import { callV1 } from "../client.js";
2
+ const USAGE = "Usage: sendoka events trigger <message.bounced|...> --endpoint <whk_id> [--data '<json object>']";
3
+ /**
4
+ * Fire a synthetic, signed delivery at one of your endpoints —
5
+ * `POST /api/v1/webhooks/{id}/test-fire` with the API key (`write:webhooks`).
6
+ *
7
+ * This used to call `/api/internal/webhooks/{id}/trigger-test` with a session
8
+ * cookie under the name NextAuth only uses over plain http, so it answered 401
9
+ * against the default https host on every run. The v1 route is the same
10
+ * operation — same payload shape, same signing, same 20/min budget shared with
11
+ * the dashboard's test button — reachable with the key every other command
12
+ * already reads.
13
+ */
14
+ export async function eventsTrigger(flags) {
15
+ // `--event` and the positional form are equivalent; the flag wins if both
16
+ // are given, since it is the one that cannot be a mistyped stray word.
17
+ const eventName = typeof flags.event === "string" ? flags.event : typeof flags._ === "string" ? flags._ : null;
18
+ const endpoint = typeof flags.endpoint === "string" ? flags.endpoint : null;
19
+ if (!eventName || !endpoint)
20
+ throw new Error(USAGE);
21
+ const data = parseDataFlag(flags.data);
22
+ const res = await callV1("POST", `/webhooks/${encodeURIComponent(endpoint)}/test-fire`, data ? { event: eventName, data } : { event: eventName });
23
+ process.stdout.write(JSON.stringify(res, null, 2) + "\n");
24
+ }
25
+ /**
26
+ * `--data` is merged over the synthetic `data` object server-side, so it has
27
+ * to be a JSON object: the route validates it as a record and would answer an
28
+ * array or a scalar with a 422 after the round trip. Refused here instead.
29
+ */
30
+ function parseDataFlag(v) {
31
+ if (v === undefined)
32
+ return undefined;
33
+ if (typeof v !== "string") {
34
+ throw new Error(`--data needs a JSON object, e.g. --data '{"channel":"sms"}'`);
35
+ }
36
+ let parsed;
37
+ try {
38
+ parsed = JSON.parse(v);
39
+ }
40
+ catch {
41
+ throw new Error(`--data must be JSON (got ${JSON.stringify(v)})`);
42
+ }
43
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
44
+ throw new Error(`--data must be a JSON object (got ${JSON.stringify(v)})`);
45
+ }
46
+ return parsed;
47
+ }
@@ -0,0 +1,30 @@
1
+ import { sendEmail, sendSms } from "./send.js";
2
+ import { logsTail } from "./logs.js";
3
+ import { listen } from "./listen.js";
4
+ import { eventsTrigger } from "./events.js";
5
+ const TABLE = {
6
+ send: {
7
+ email: sendEmail,
8
+ sms: sendSms,
9
+ },
10
+ logs: {
11
+ tail: logsTail,
12
+ },
13
+ listen: {
14
+ "": listen,
15
+ },
16
+ events: {
17
+ trigger: eventsTrigger,
18
+ },
19
+ };
20
+ export function resolveCommand(group, action) {
21
+ const grp = TABLE[group];
22
+ if (!grp)
23
+ return null;
24
+ // A flag where the action would be means the group itself is the command —
25
+ // `sendoka listen --forward-to <url>`. Treating `--forward-to` as an action
26
+ // resolved nothing and made the only group-only command unreachable.
27
+ if (!action || action.startsWith("-"))
28
+ return grp[""] ?? null;
29
+ return grp[action] ?? null;
30
+ }
@@ -0,0 +1,379 @@
1
+ import { createHmac } from "node:crypto";
2
+ import { ApiError, callV1, isRateLimited } from "../client.js";
3
+ const PAGE_SIZE = 50;
4
+ /** Pages walked per poll before giving up on reaching already-seen rows. */
5
+ const MAX_PAGES = 10;
6
+ /**
7
+ * Upper bound on remembered delivery ids. The list is newest-first and a poll
8
+ * stops at the first page holding a remembered id, so only the newest ids ever
9
+ * matter; dropping the oldest keeps a day-long session from growing without
10
+ * bound.
11
+ */
12
+ const SEEN_CAP = 5000;
13
+ /**
14
+ * Polls a delivery's payload may fail to load (5xx, network) before it is
15
+ * skipped. A 429 is not a failure and never counts: the row is still there,
16
+ * the org's budget is not.
17
+ */
18
+ const MAX_FETCH_ATTEMPTS = 3;
19
+ /** Pause after a 429 that carried no usable `Retry-After`. */
20
+ const DEFAULT_BACKOFF_MS = 30_000;
21
+ /**
22
+ * How far behind the newest delivery a poll has read the floor is kept. A
23
+ * row's `created_at` is stamped when it is inserted, and one can become
24
+ * visible a moment after a newer one; rows inside this margin are still
25
+ * walked, and the seen-set keeps them from being forwarded twice.
26
+ */
27
+ const LATE_COMMIT_SLACK_MS = 60_000;
28
+ const USAGE = "Usage: sendoka listen --endpoint <whk_id> --forward-to <url> [--secret <whsec_...>] [--interval 5]";
29
+ /**
30
+ * The request `attemptDelivery` (src/lib/api/webhook-fanout.ts) would have
31
+ * sent, rebuilt from a stored delivery. Byte-for-byte the same shape: the body
32
+ * is the stored payload with `delivery_id` stamped on, and the three signature
33
+ * headers are computed exactly as the server computes them —
34
+ *
35
+ * X-Sendoka-Signature HMAC-SHA256(secret, body) legacy
36
+ * X-Sendoka-Timestamp unix seconds
37
+ * X-Sendoka-Signature-V2 HMAC-SHA256(secret, `${timestamp}.${body}`)
38
+ *
39
+ * so a local handler calling the SDK's `verifyWebhookSignature` (or the
40
+ * hand-rolled check in docs/recipes/verify-webhooks.md) accepts it unchanged.
41
+ * The timestamp is the moment of forwarding, not of the original attempt, or
42
+ * the SDK's five-minute replay window would reject anything older.
43
+ *
44
+ * Without a secret the signature headers are omitted rather than faked: a
45
+ * handler that verifies then rejects the request, which is the honest outcome.
46
+ */
47
+ export function buildForwardRequest(delivery, secret, nowMs = Date.now()) {
48
+ const payload = delivery.payload ?? {};
49
+ const body = JSON.stringify({ ...payload, delivery_id: delivery.id });
50
+ const timestamp = Math.floor(nowMs / 1000).toString();
51
+ const headers = {
52
+ "Content-Type": "application/json",
53
+ "X-Sendoka-Timestamp": timestamp,
54
+ "X-Sendoka-Event": delivery.event,
55
+ "X-Sendoka-Delivery-Id": delivery.id,
56
+ "X-Sendoka-Forwarded": "cli",
57
+ };
58
+ if (secret) {
59
+ headers["X-Sendoka-Signature"] = createHmac("sha256", secret).update(body).digest("hex");
60
+ headers["X-Sendoka-Signature-V2"] = createHmac("sha256", secret)
61
+ .update(`${timestamp}.${body}`)
62
+ .digest("hex");
63
+ }
64
+ if (typeof payload.environment === "string") {
65
+ headers["X-Sendoka-Environment"] = payload.environment;
66
+ }
67
+ if (typeof payload.tenant_id === "string" && payload.tenant_id) {
68
+ headers["X-Sendoka-Tenant-Id"] = payload.tenant_id;
69
+ }
70
+ return { body, headers };
71
+ }
72
+ /**
73
+ * The instant a `next_cursor` resumes below, as an ISO string, or null when it
74
+ * cannot be read.
75
+ *
76
+ * Cursors are opaque to API clients, but this CLI ships from the same
77
+ * repository as the API: a cursor is base64url(`{ISO}|{id}`)
78
+ * (src/lib/api/cursor.ts). It is read because of a test key's list: a page
79
+ * stops after reading a bounded slice of the endpoint (mostly live rows it may
80
+ * not see), so it can come back short or EMPTY with `has_more`, and then only
81
+ * its cursor says how far back it read. Anything unreadable answers null and
82
+ * is treated as unknown, never as a time.
83
+ */
84
+ export function cursorTime(cursor) {
85
+ if (!cursor)
86
+ return null;
87
+ try {
88
+ const [iso, id] = Buffer.from(cursor, "base64url").toString("utf8").split("|");
89
+ if (!iso || !id)
90
+ return null;
91
+ const ms = Date.parse(iso);
92
+ return Number.isFinite(ms) && /^\d{4}-\d{2}-\d{2}T/.test(iso) ? new Date(ms).toISOString() : null;
93
+ }
94
+ catch {
95
+ return null;
96
+ }
97
+ }
98
+ /**
99
+ * The polling state machine behind `sendoka listen`, separated from the
100
+ * infinite loop so it can be driven one step at a time.
101
+ *
102
+ * `prime()` records what already exists and forwards none of it — the command
103
+ * starts from now. The old version seeded an empty seen-set, so its first poll
104
+ * re-POSTed the twenty most recent deliveries, whatever their age, and every
105
+ * restart replayed them again. What it records is a FLOOR: the newest
106
+ * delivery the key can see, everything older than which is history. A test
107
+ * key's page can be empty with more to follow (it read a slice of the
108
+ * endpoint's live traffic and found none of its own), so prime follows the
109
+ * list until it has one — from the first row it finds, or, for a page that
110
+ * found none, from the page's cursor, below which everything it did not see
111
+ * lies — and failing both, starts from the current time.
112
+ *
113
+ * `poll()` walks the newest-first list until it reaches a delivery it has
114
+ * already seen or one older than the floor — or a cursor that resumes below
115
+ * the floor, which is how a walk through empty pages ends — then forwards
116
+ * everything new oldest-first. Within that last page it keeps scanning rather
117
+ * than stopping at the first known row, so a row that committed slightly out
118
+ * of `created_at` order is still caught. A walk that got to the end raises the
119
+ * floor to a minute behind the newest point it read (never past a delivery
120
+ * still waiting to be forwarded), so the next one does not re-read the
121
+ * endpoint's traffic since startup.
122
+ *
123
+ * Every request here counts against the org-wide `/v1` rate limit — the same
124
+ * budget the org's production sends draw on (60/min on Free), test keys
125
+ * included. So the list is read with `include=payload`, one request per page
126
+ * rather than one more per delivery, and a 429 pauses the loop for the
127
+ * server's `Retry-After` instead of pressing on. A delivery is only
128
+ * remembered once its payload is in hand (or the server says it is gone), so
129
+ * one that could not be loaded is still new on the next poll; the ones behind
130
+ * it wait too, which keeps them in order. Failing to reach `--forward-to` is
131
+ * different: that is reported once and not retried.
132
+ */
133
+ export function createForwarder(opts) {
134
+ const log = opts.log ?? ((l) => console.log(l));
135
+ const warn = opts.warn ?? ((l) => console.error(l));
136
+ const now = opts.now ?? Date.now;
137
+ const base = `/webhooks/${encodeURIComponent(opts.endpoint)}/deliveries`;
138
+ const seen = new Set();
139
+ const failures = new Map();
140
+ let floor = null;
141
+ let backoffUntil = 0;
142
+ function remember(id) {
143
+ seen.add(id);
144
+ failures.delete(id);
145
+ if (seen.size > SEEN_CAP) {
146
+ const oldest = seen.values().next().value;
147
+ if (oldest !== undefined)
148
+ seen.delete(oldest);
149
+ }
150
+ }
151
+ function backOff(err, pending) {
152
+ const ms = err.retryAfterSeconds !== null ? err.retryAfterSeconds * 1000 : DEFAULT_BACKOFF_MS;
153
+ backoffUntil = Math.max(backoffUntil, now() + ms);
154
+ warn(`Rate limited (429) — this org's /v1 budget is spent. Pausing ${Math.ceil(ms / 1000)}s` +
155
+ (pending > 0 ? `; ${pending} ${pending === 1 ? "delivery waits" : "deliveries wait"} for the next poll.` : "."));
156
+ }
157
+ function listPath(cursor) {
158
+ return `${base}?limit=${PAGE_SIZE}&include=payload${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`;
159
+ }
160
+ async function prime() {
161
+ let cursor = null;
162
+ for (let pages = 0; pages < MAX_PAGES; pages++) {
163
+ // Ids only: nothing here is forwarded, so its payloads are not asked for.
164
+ const page = await callV1("GET", `${base}?limit=${PAGE_SIZE}${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`);
165
+ if (page.data.length > 0) {
166
+ // Newest first, so the first row found is the newest the key can see.
167
+ // Oldest first into the set, so the newest ids are the last evicted.
168
+ for (const row of [...page.data].reverse())
169
+ remember(row.id);
170
+ floor = page.data[0].created_at;
171
+ return page.data.length;
172
+ }
173
+ // Nothing visible, and nothing more to read: there is no history to
174
+ // skip, and every delivery from here on is new.
175
+ if (!page.has_more || !page.next_cursor)
176
+ return 0;
177
+ // An empty page with more to follow read everything newer than its
178
+ // cursor and found nothing this key may see, so anything older is all
179
+ // there is. That is the floor.
180
+ const below = cursorTime(page.next_cursor);
181
+ if (below) {
182
+ floor = below;
183
+ return 0;
184
+ }
185
+ cursor = page.next_cursor;
186
+ }
187
+ // A server whose cursor this cannot read, and pages of nothing: start
188
+ // from now rather than forward whatever lies further back.
189
+ floor = new Date(now()).toISOString();
190
+ return 0;
191
+ }
192
+ /**
193
+ * How long to wait before the next poll: the interval, or longer while a
194
+ * 429's `Retry-After` is still running.
195
+ */
196
+ function delayBeforeNextPoll(intervalMs) {
197
+ return Math.max(intervalMs, backoffUntil - now());
198
+ }
199
+ async function poll() {
200
+ const fresh = [];
201
+ let cursor = null;
202
+ // The newest point this walk read: its newest row, or where an empty
203
+ // first page's slice ended. Null until known.
204
+ let newest = null;
205
+ // Whether the walk reached what was already known (or the list's end).
206
+ let complete = false;
207
+ for (let pages = 0;; pages++) {
208
+ if (pages === MAX_PAGES) {
209
+ // Only what is true: pages of a test key's list can be short or empty,
210
+ // so a page count says nothing about how many deliveries were new.
211
+ warn(`Read ${MAX_PAGES} pages without reaching deliveries already seen; forwarding the ${fresh.length} new ${fresh.length === 1 ? "one" : "ones"} found, and skipping anything older.`);
212
+ break;
213
+ }
214
+ let page;
215
+ try {
216
+ page = await callV1("GET", listPath(cursor));
217
+ }
218
+ catch (err) {
219
+ if (!isRateLimited(err))
220
+ throw err;
221
+ // Nothing is remembered yet, so the whole walk simply happens again.
222
+ backOff(err, 0);
223
+ return 0;
224
+ }
225
+ let reachedKnown = false;
226
+ for (const row of page.data) {
227
+ if (newest === null || row.created_at > newest)
228
+ newest = row.created_at;
229
+ if (seen.has(row.id) || (floor !== null && row.created_at < floor)) {
230
+ reachedKnown = true;
231
+ continue;
232
+ }
233
+ fresh.push(row);
234
+ }
235
+ const next = page.has_more ? page.next_cursor : null;
236
+ const below = cursorTime(next);
237
+ if (pages === 0 && page.data.length === 0 && below)
238
+ newest = below;
239
+ // Past the floor, everything is history: the end of a walk through
240
+ // empty pages, which have no row to compare.
241
+ if (reachedKnown || !next || (floor !== null && below !== null && below < floor)) {
242
+ complete = true;
243
+ break;
244
+ }
245
+ cursor = next;
246
+ }
247
+ fresh.sort((a, b) => a.created_at === b.created_at ? a.id.localeCompare(b.id) : a.created_at.localeCompare(b.created_at));
248
+ let forwarded = 0;
249
+ let waiting = null;
250
+ for (const [i, row] of fresh.entries()) {
251
+ const detail = await load(row, fresh.length - i);
252
+ if (detail === "stop") {
253
+ // This row and every newer one wait for the next poll.
254
+ waiting = row.created_at;
255
+ break;
256
+ }
257
+ // Remembered once the payload is in hand, before the local POST: a
258
+ // handler that is down is reported once rather than retried every
259
+ // interval.
260
+ remember(row.id);
261
+ if (detail === "skip")
262
+ continue;
263
+ await forward(row, detail);
264
+ forwarded++;
265
+ }
266
+ if (complete)
267
+ raiseFloor(newest, waiting);
268
+ return forwarded;
269
+ }
270
+ /**
271
+ * After a complete walk: move the floor up to LATE_COMMIT_SLACK_MS behind
272
+ * the newest point read, but never past a delivery still waiting, and never
273
+ * down.
274
+ */
275
+ function raiseFloor(newest, waiting) {
276
+ if (newest === null)
277
+ return;
278
+ let candidate = new Date(Date.parse(newest) - LATE_COMMIT_SLACK_MS).toISOString();
279
+ if (waiting !== null && waiting < candidate)
280
+ candidate = waiting;
281
+ if (floor === null || candidate > floor)
282
+ floor = candidate;
283
+ }
284
+ /**
285
+ * The payload for one row: from the list when the server sent it, else the
286
+ * drill-down. "stop" ends this poll with the row (and every newer one)
287
+ * unremembered, so the next poll finds them again; "skip" drops the row.
288
+ */
289
+ async function load(row, pending) {
290
+ if (row.payload !== undefined) {
291
+ return { id: row.id, event: row.event, payload: row.payload, created_at: row.created_at };
292
+ }
293
+ try {
294
+ return await callV1("GET", `${base}/${encodeURIComponent(row.id)}`);
295
+ }
296
+ catch (err) {
297
+ if (isRateLimited(err)) {
298
+ backOff(err, pending);
299
+ return "stop";
300
+ }
301
+ if (err instanceof ApiError && err.status === 404) {
302
+ // Deleted with its endpoint, or no longer in scope: it will not come back.
303
+ warn(`[${row.id}] no longer exists; skipped.`);
304
+ return "skip";
305
+ }
306
+ const attempts = (failures.get(row.id) ?? 0) + 1;
307
+ failures.set(row.id, attempts);
308
+ if (attempts >= MAX_FETCH_ATTEMPTS) {
309
+ warn(`[${row.id}] could not fetch payload after ${attempts} polls; skipped: ${err.message}`);
310
+ return "skip";
311
+ }
312
+ warn(`[${row.id}] could not fetch payload (retrying next poll): ${err.message}`);
313
+ return "stop";
314
+ }
315
+ }
316
+ async function forward(row, detail) {
317
+ const { body, headers } = buildForwardRequest(detail, opts.secret);
318
+ try {
319
+ const res = await fetch(opts.forwardTo, { method: "POST", headers, body });
320
+ log(`[${row.created_at}] ${row.event.padEnd(22)} ${row.id} → ${res.status}`);
321
+ }
322
+ catch (err) {
323
+ warn(`[${row.id}] forward failed: ${err.message}`);
324
+ }
325
+ }
326
+ return { prime, poll, delayBeforeNextPoll };
327
+ }
328
+ /**
329
+ * `sendoka listen` — forward one endpoint's webhook deliveries to a local URL,
330
+ * re-signed with the endpoint's secret, so a local handler receives real
331
+ * events, signature check included, without a public tunnel.
332
+ *
333
+ * Reads `GET /api/v1/webhooks/{id}/deliveries` (`read:webhooks`) with the API
334
+ * key, one request per page per interval — against the same org-wide `/v1`
335
+ * rate limit as production traffic, which is why `--interval` exists and a 429
336
+ * pauses the loop. It used to read `/api/internal/webhook-deliveries` with a session cookie
337
+ * under the unprefixed NextAuth name, which the https host never reads — a 401
338
+ * on every poll against www.sendoka.com.
339
+ */
340
+ export async function listen(flags) {
341
+ const endpoint = typeof flags.endpoint === "string" ? flags.endpoint : null;
342
+ const forwardTo = typeof flags["forward-to"] === "string" ? flags["forward-to"] : null;
343
+ if (!endpoint || !forwardTo)
344
+ throw new Error(USAGE);
345
+ // `new URL("localhost:3001")` parses — as scheme `localhost:` — so the
346
+ // protocol is checked, not just that it parses.
347
+ let protocol = null;
348
+ try {
349
+ protocol = new URL(forwardTo).protocol;
350
+ }
351
+ catch {
352
+ // reported below
353
+ }
354
+ if (protocol !== "http:" && protocol !== "https:") {
355
+ throw new Error(`--forward-to must be an absolute http(s) URL, e.g. http://localhost:3001/hooks (got ${JSON.stringify(forwardTo)})`);
356
+ }
357
+ const intervalSeconds = typeof flags.interval === "string" ? Number(flags.interval) : 5;
358
+ if (!Number.isFinite(intervalSeconds) || intervalSeconds <= 0) {
359
+ throw new Error("--interval must be a positive number of seconds");
360
+ }
361
+ const secret = typeof flags.secret === "string" ? flags.secret : process.env.SENDOKA_WEBHOOK_SECRET || null;
362
+ const forwarder = createForwarder({ endpoint, forwardTo, secret });
363
+ // Outside the loop on purpose: a bad key, a missing scope or an unknown
364
+ // endpoint is a setup error and exits, rather than printing forever.
365
+ await forwarder.prime();
366
+ if (!secret) {
367
+ console.error("No --secret or SENDOKA_WEBHOOK_SECRET: forwarding UNSIGNED. A handler that verifies signatures will reject these.");
368
+ }
369
+ console.log(`Forwarding new deliveries for ${endpoint} → ${forwardTo} every ${intervalSeconds}s. Ctrl+C to stop.`);
370
+ for (;;) {
371
+ await new Promise((r) => setTimeout(r, forwarder.delayBeforeNextPoll(intervalSeconds * 1000)));
372
+ try {
373
+ await forwarder.poll();
374
+ }
375
+ catch (err) {
376
+ console.error(err.message);
377
+ }
378
+ }
379
+ }
@@ -0,0 +1,157 @@
1
+ import { callV1, isRateLimited } from "../client.js";
2
+ const PAGE_SIZE = 100;
3
+ const MAX_PAGES = 10;
4
+ /**
5
+ * How far back each poll re-reads behind the newest row it has printed.
6
+ * `created_at` is stamped at insert, not at commit, so a send that commits a
7
+ * moment after a later one lands *behind* the watermark; without the overlap
8
+ * it would never be printed. Duplicates from the overlap are dropped by id.
9
+ */
10
+ const OVERLAP_MS = 5_000;
11
+ const PATHS = { email: "/emails", sms: "/sms" };
12
+ const USAGE = "Usage: sendoka logs tail [--channel email|sms] [--interval 5]";
13
+ export function formatMessage(row) {
14
+ const route = `${row.from ?? "?"} → ${row.to ?? "?"}`;
15
+ const subject = row.channel === "email" && row.subject ? ` ${JSON.stringify(row.subject)}` : "";
16
+ return `[${row.created_at}] ${row.channel.padEnd(5)} ${row.status.padEnd(10)} ${row.id} ${route}${subject}`;
17
+ }
18
+ /**
19
+ * One channel's tail: the list endpoint's `created_after` filter from a
20
+ * watermark taken off the server's own clock, so client clock skew cannot drop
21
+ * or replay messages. `prime()` records what already exists and prints
22
+ * nothing — the tail starts from now.
23
+ */
24
+ export function createTail(channel, print, warn = (line) => console.error(line)) {
25
+ const path = PATHS[channel];
26
+ // id → created_at of every row inside the overlap window, printed or not.
27
+ const seen = new Map();
28
+ let watermark = null;
29
+ function overlapStart(at, factor = 1) {
30
+ return new Date(new Date(at).getTime() - factor * OVERLAP_MS).toISOString();
31
+ }
32
+ /** Every row created at or after `after` (all rows when null), newest first. */
33
+ async function fetchSince(after) {
34
+ const rows = [];
35
+ let cursor = null;
36
+ for (let pages = 0; pages < MAX_PAGES; pages++) {
37
+ const qs = new URLSearchParams({ limit: String(PAGE_SIZE) });
38
+ if (after)
39
+ qs.set("created_after", after);
40
+ if (cursor)
41
+ qs.set("cursor", cursor);
42
+ const page = await callV1("GET", `${path}?${qs}`);
43
+ rows.push(...page.data.map((r) => ({ ...r, channel })));
44
+ if (!page.has_more || !page.next_cursor)
45
+ return { rows, truncated: false };
46
+ cursor = page.next_cursor;
47
+ }
48
+ return { rows, truncated: true };
49
+ }
50
+ /**
51
+ * The watermark and the seen-set come from ONE read. The first page, with
52
+ * no filter, fixes the watermark; any further pages follow its keyset
53
+ * cursor, which only ever returns rows older than that page's last. So a
54
+ * message created while the tail is starting cannot be marked seen here and
55
+ * then never printed — it used to be, when a `limit=1` read set the
56
+ * watermark and a second, later `created_after` read filled the seen-set.
57
+ */
58
+ async function prime() {
59
+ let cursor = null;
60
+ for (let pages = 0; pages < MAX_PAGES; pages++) {
61
+ const qs = new URLSearchParams({ limit: String(PAGE_SIZE) });
62
+ if (cursor)
63
+ qs.set("cursor", cursor);
64
+ const page = await callV1("GET", `${path}?${qs}`);
65
+ if (watermark === null) {
66
+ const newest = page.data[0];
67
+ if (!newest)
68
+ return;
69
+ watermark = newest.created_at;
70
+ }
71
+ // The first poll re-reads the overlap behind the watermark, so every row
72
+ // already in it is history too — not only the single newest one.
73
+ const from = overlapStart(watermark);
74
+ for (const row of page.data)
75
+ if (row.created_at >= from)
76
+ seen.set(row.id, row.created_at);
77
+ const last = page.data[page.data.length - 1];
78
+ if (!last || last.created_at < from || !page.has_more || !page.next_cursor)
79
+ return;
80
+ cursor = page.next_cursor;
81
+ }
82
+ }
83
+ async function poll() {
84
+ const { rows, truncated } = await fetchSince(watermark ? overlapStart(watermark) : null);
85
+ const fresh = rows.filter((row) => !seen.has(row.id));
86
+ if (truncated) {
87
+ warn(`More than ${MAX_PAGES * PAGE_SIZE} new ${channel} messages since the last poll; printing the newest ${fresh.length}.`);
88
+ }
89
+ fresh.sort((a, b) => a.created_at === b.created_at ? a.id.localeCompare(b.id) : a.created_at.localeCompare(b.created_at));
90
+ for (const row of fresh) {
91
+ seen.set(row.id, row.created_at);
92
+ if (!watermark || row.created_at > watermark)
93
+ watermark = row.created_at;
94
+ print(formatMessage(row));
95
+ }
96
+ // Only ids inside the next poll's overlap window can come back.
97
+ if (watermark) {
98
+ const keepFrom = overlapStart(watermark, 2);
99
+ for (const [id, at] of seen)
100
+ if (at < keepFrom)
101
+ seen.delete(id);
102
+ }
103
+ return fresh.length;
104
+ }
105
+ return { prime, poll };
106
+ }
107
+ /**
108
+ * `sendoka logs tail` — print each message as it is created.
109
+ *
110
+ * Polls `GET /api/v1/emails` and `GET /api/v1/sms` (`read:messages`) with
111
+ * `created_after`. It used to poll `/api/v1/activity` and print daily
112
+ * aggregate rows — one line per (date, channel, status), reprinted whenever a
113
+ * count moved — which answered "how many" but never "what just happened".
114
+ * Status changes after a message is first printed are not re-printed; that is
115
+ * what webhooks and `sendoka listen` are for.
116
+ *
117
+ * One request per channel per interval (24/min at the defaults), all counted
118
+ * against the org-wide `/v1` rate limit that production sends also draw on.
119
+ */
120
+ export async function logsTail(flags) {
121
+ const intervalSeconds = typeof flags.interval === "string" ? Number(flags.interval) : 5;
122
+ if (!Number.isFinite(intervalSeconds) || intervalSeconds <= 0) {
123
+ throw new Error("--interval must be a positive number of seconds");
124
+ }
125
+ let channels = ["email", "sms"];
126
+ if (flags.channel !== undefined) {
127
+ if (flags.channel !== "email" && flags.channel !== "sms")
128
+ throw new Error(USAGE);
129
+ channels = [flags.channel];
130
+ }
131
+ if (flags.days !== undefined) {
132
+ console.error("--days is no longer used: logs tail now prints individual messages as they are created, starting now.");
133
+ }
134
+ const tails = channels.map((c) => createTail(c, (line) => console.log(line)));
135
+ // A bad key or a missing read:messages scope is a setup error: exit on it
136
+ // rather than printing the same 403 every interval.
137
+ await Promise.all(tails.map((t) => t.prime()));
138
+ console.log(`Tailing new ${channels.join(" + ")} messages. Ctrl+C to stop.`);
139
+ let pauseMs = 0;
140
+ for (;;) {
141
+ await new Promise((r) => setTimeout(r, Math.max(intervalSeconds * 1000, pauseMs)));
142
+ pauseMs = 0;
143
+ for (const tail of tails) {
144
+ try {
145
+ await tail.poll();
146
+ }
147
+ catch (err) {
148
+ console.error(err.message);
149
+ // Each poll spends the org-wide /v1 budget production sends share; on
150
+ // a 429, wait out the server's Retry-After rather than the interval.
151
+ if (isRateLimited(err)) {
152
+ pauseMs = Math.max(pauseMs, (err.retryAfterSeconds ?? 30) * 1000);
153
+ }
154
+ }
155
+ }
156
+ }
157
+ }
@@ -0,0 +1,46 @@
1
+ import { callV1 } from "../client.js";
2
+ function str(flags, key) {
3
+ const v = flags[key];
4
+ return typeof v === "string" ? v : undefined;
5
+ }
6
+ export async function sendEmail(flags) {
7
+ const from = str(flags, "from");
8
+ const to = str(flags, "to");
9
+ if (!from || !to)
10
+ throw new Error("--from and --to are required");
11
+ const res = await callV1("POST", "/emails", {
12
+ from,
13
+ to: to.split(",").map((s) => s.trim()),
14
+ subject: str(flags, "subject"),
15
+ html: str(flags, "html"),
16
+ text: str(flags, "text"),
17
+ template: str(flags, "template"),
18
+ variables: parseJson(str(flags, "variables")),
19
+ });
20
+ process.stdout.write(JSON.stringify(res, null, 2) + "\n");
21
+ }
22
+ export async function sendSms(flags) {
23
+ const from = str(flags, "from");
24
+ const to = str(flags, "to");
25
+ const body = str(flags, "body");
26
+ if (!to || !body)
27
+ throw new Error("--to and --body are required");
28
+ const res = await callV1("POST", "/sms", {
29
+ from,
30
+ to,
31
+ body,
32
+ template: str(flags, "template"),
33
+ variables: parseJson(str(flags, "variables")),
34
+ });
35
+ process.stdout.write(JSON.stringify(res, null, 2) + "\n");
36
+ }
37
+ function parseJson(v) {
38
+ if (!v)
39
+ return undefined;
40
+ try {
41
+ return JSON.parse(v);
42
+ }
43
+ catch {
44
+ throw new Error(`--variables must be JSON (got ${JSON.stringify(v)})`);
45
+ }
46
+ }
package/dist/flags.js ADDED
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Argv → flag bag for everything after `<group> <action>`.
3
+ *
4
+ * It used to skip anything not starting with `--` outright, which silently
5
+ * threw away every positional argument. Only one command takes one — and it is
6
+ * the shape the CLI's own usage text advertises:
7
+ *
8
+ * sendoka events trigger message.bounced --endpoint whk_01HN...
9
+ *
10
+ * `message.bounced` was dropped on the floor, `eventsTrigger` found no event
11
+ * name in the bag, and the command's only outcome was the usage error telling
12
+ * you to run exactly what you had just run. The documented invocation could
13
+ * never succeed.
14
+ *
15
+ * The first positional now lands under `_`, which is where `eventsTrigger` was
16
+ * already looking for it. No command takes a second one, so later positionals
17
+ * are collected under `_1`, `_2`, … rather than overwriting `_` — a mistyped
18
+ * extra word must not silently change which event is fired.
19
+ */
20
+ export function parseFlags(args) {
21
+ const out = {};
22
+ let positional = 0;
23
+ for (let i = 0; i < args.length; i++) {
24
+ const a = args[i];
25
+ if (!a.startsWith("--")) {
26
+ out[positional === 0 ? "_" : `_${positional}`] = a;
27
+ positional++;
28
+ continue;
29
+ }
30
+ const key = a.slice(2);
31
+ const next = args[i + 1];
32
+ if (!next || next.startsWith("--")) {
33
+ out[key] = true;
34
+ }
35
+ else {
36
+ out[key] = next;
37
+ i++;
38
+ }
39
+ }
40
+ return out;
41
+ }
42
+ /**
43
+ * Splits everything after the node binary and the script — `<group> [action]
44
+ * [...args]` — into the three pieces `main` dispatches on.
45
+ *
46
+ * The second token counts as an action only when it does not look like a flag.
47
+ * `sendoka listen --forward-to http://localhost:3001/hooks` used to be read as
48
+ * group `listen`, action `--forward-to`: `listen` is the one group-only
49
+ * command (keyed `""` in the table), no `--forward-to` entry exists, so the
50
+ * CLI printed `Unknown command: listen --forward-to` and exited 2 — while the
51
+ * URL that followed was swallowed as the action's argument. Bare `sendoka
52
+ * listen` then failed its own `--forward-to <url> is required` check, so the
53
+ * command was unusable in every shape the docs advertise.
54
+ */
55
+ export function splitInvocation(rest) {
56
+ const [group, ...tail] = rest;
57
+ const flagFirst = tail.length > 0 && tail[0].startsWith("-");
58
+ return {
59
+ group,
60
+ action: flagFirst ? undefined : tail[0],
61
+ args: flagFirst ? tail : tail.slice(1),
62
+ };
63
+ }
@@ -0,0 +1,16 @@
1
+ import { createRequire } from "node:module";
2
+ /**
3
+ * Read from package.json rather than restated, the way @sendoka/dev-mcp does:
4
+ * `--version` printed a hard-coded "0.1.0" that nothing kept in step with the
5
+ * published version. `../package.json` resolves from both `dist/version.js`
6
+ * and `src/version.ts`, and npm always ships package.json.
7
+ */
8
+ export const CLI_VERSION = (() => {
9
+ try {
10
+ const require = createRequire(import.meta.url);
11
+ return require("../package.json").version ?? "0.0.0";
12
+ }
13
+ catch {
14
+ return "0.0.0";
15
+ }
16
+ })();
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@sendoka/cli",
3
+ "version": "0.2.2",
4
+ "description": "Sendoka command-line tools for developer workflows.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "sendoka": "dist/cli.js"
9
+ },
10
+ "files": ["dist", "README.md"],
11
+ "scripts": {
12
+ "build": "tsc -p tsconfig.json",
13
+ "dev": "tsc -p tsconfig.json --watch",
14
+ "prepack": "npm run build"
15
+ },
16
+ "publishConfig": {
17
+ "access": "public"
18
+ },
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/fareed010/Sendoka.git",
22
+ "directory": "packages/cli"
23
+ },
24
+ "dependencies": {},
25
+ "devDependencies": {
26
+ "@types/node": "^22.0.0",
27
+ "typescript": "^5.6.0"
28
+ },
29
+ "engines": {
30
+ "node": ">=20"
31
+ }
32
+ }