@hogsend/cli 0.2.2 → 0.6.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/bin.js +904 -168
- package/dist/bin.js.map +1 -1
- package/package.json +2 -2
- package/skills/hogsend-authoring-buckets/SKILL.md +202 -23
- package/skills/hogsend-authoring-buckets/references/bucket-id-aliases.md +74 -59
- package/skills/hogsend-authoring-buckets/references/bucket-meta.md +19 -4
- package/skills/hogsend-authoring-buckets/references/buckets-vs-journeys.md +52 -8
- package/skills/hogsend-authoring-buckets/references/register-a-bucket.md +58 -24
- package/skills/hogsend-authoring-journeys/SKILL.md +1 -1
- package/skills/hogsend-authoring-journeys/references/journey-meta.md +8 -3
- package/skills/hogsend-authoring-lists/SKILL.md +178 -0
- package/skills/hogsend-cli/SKILL.md +44 -18
- package/skills/hogsend-client-sdk/SKILL.md +185 -0
- package/skills/hogsend-client-sdk/references/api-surface.md +181 -0
- package/src/bin.ts +4 -1
- package/src/commands/campaigns.ts +309 -0
- package/src/commands/contacts.ts +176 -6
- package/src/commands/emails.ts +231 -0
- package/src/commands/events.ts +253 -15
- package/src/commands/index.ts +4 -0
- package/src/commands/types.ts +8 -2
- package/src/lib/config.ts +23 -1
- package/src/lib/http.ts +122 -49
- package/studio/assets/index-BNDE5JtQ.css +1 -0
- package/studio/assets/{index-B49mArEh.js → index-D7Ax_oFF.js} +11 -11
- package/studio/index.html +2 -2
- package/studio/assets/index-CycKZchB.css +0 -1
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
# `@hogsend/client` — full API surface
|
|
2
|
+
|
|
3
|
+
Every method on the `Hogsend` instance, with its input and result shape and the
|
|
4
|
+
data-plane route it maps to. All inputs that mutate require an **identity** —
|
|
5
|
+
at least one of `email` or `userId` (both may be supplied), enforced by
|
|
6
|
+
`assertIdentity` at runtime and the `Identity` union at compile time.
|
|
7
|
+
|
|
8
|
+
## `hs.contacts`
|
|
9
|
+
|
|
10
|
+
### `contacts.upsert(input) → { id, created, linked }`
|
|
11
|
+
|
|
12
|
+
`PUT /v1/contacts`. Upsert a contact by identity; the server resolves/merges
|
|
13
|
+
(including identity linking when both `email` and `userId` are given).
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
type UpsertContactInput = Identity & {
|
|
17
|
+
properties?: Record<string, unknown>; // merged onto the contact
|
|
18
|
+
lists?: Record<string, boolean>; // inline list membership flips
|
|
19
|
+
};
|
|
20
|
+
// → { id: string; created: boolean; linked: boolean }
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
- `created` — `true` when a brand-new contact row was inserted.
|
|
24
|
+
- `linked` — `true` when the upsert merged two previously-separate identities
|
|
25
|
+
(an `email`-only and a `userId`-only contact) into one.
|
|
26
|
+
|
|
27
|
+
### `contacts.find(input) → Contact[]`
|
|
28
|
+
|
|
29
|
+
`GET /v1/contacts/find`. Look up non-deleted contacts by EXACTLY ONE of `email`
|
|
30
|
+
or `userId` (the find input is `{ email } | { userId }`, not the general
|
|
31
|
+
identity union). Returns an array (may be empty).
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
type FindContactsInput = { email: string } | { userId: string };
|
|
35
|
+
|
|
36
|
+
interface Contact {
|
|
37
|
+
id: string;
|
|
38
|
+
externalId: string | null;
|
|
39
|
+
email: string | null;
|
|
40
|
+
properties: Record<string, unknown>;
|
|
41
|
+
firstSeenAt: string; // ISO — always present
|
|
42
|
+
lastSeenAt: string; // ISO
|
|
43
|
+
createdAt: string; // ISO
|
|
44
|
+
updatedAt: string; // ISO
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### `contacts.delete(input) → { deleted }`
|
|
49
|
+
|
|
50
|
+
`DELETE /v1/contacts`. Soft-delete a contact by identity. `deleted` is `true`
|
|
51
|
+
when a matching contact was found and marked deleted.
|
|
52
|
+
|
|
53
|
+
## `hs.events`
|
|
54
|
+
|
|
55
|
+
### `events.send(input) → { stored, exits, listsError? }` · alias `events.track`
|
|
56
|
+
|
|
57
|
+
`POST /v1/events` → **202 Accepted**. Push an event through the full ingestion
|
|
58
|
+
pipeline (store → route to Hatchet/journeys → evaluate `exitOn` → upsert contact).
|
|
59
|
+
`events.track` is a literal alias of `events.send`.
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
type SendEventInput = Identity & {
|
|
63
|
+
name: string;
|
|
64
|
+
eventProperties?: Record<string, unknown>; // stored ON the event → trigger.where / exitOn
|
|
65
|
+
contactProperties?: Record<string, unknown>; // merged onto the CONTACT
|
|
66
|
+
lists?: Record<string, boolean>; // inline list membership
|
|
67
|
+
idempotencyKey?: string; // dedup
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
interface IngestResult {
|
|
71
|
+
stored: boolean; // false only on idempotency-key dedup
|
|
72
|
+
exits: { journeyId: string; stateId: string; exited: boolean }[];
|
|
73
|
+
listsError?: string; // present only if the post-ingest lists write failed
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
- See the main SKILL for the `eventProperties` vs `contactProperties` split and
|
|
78
|
+
the `listsError`-on-202 semantics.
|
|
79
|
+
- **`idempotencyKey`** is sent BOTH as the `Idempotency-Key` HTTP header (which
|
|
80
|
+
wins server-side) AND in the body, matching the route. Reuse the same key to
|
|
81
|
+
make a retried `events.send` a no-op (`stored: false`).
|
|
82
|
+
|
|
83
|
+
## `hs.emails`
|
|
84
|
+
|
|
85
|
+
### `emails.send(input) → { emailSendId, status, reason? }`
|
|
86
|
+
|
|
87
|
+
`POST /v1/emails`. Send a transactional email by template through the full
|
|
88
|
+
preferences + tracking pipeline (link-click + open rewriting applied
|
|
89
|
+
automatically). Recipient is `to` (raw address) OR `userId` (resolved to the
|
|
90
|
+
contact's email server-side).
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
type SendEmailInput = SendEmailEnvelope & ( typed-or-untyped template variant );
|
|
94
|
+
|
|
95
|
+
type SendEmailEnvelope = {
|
|
96
|
+
to?: string;
|
|
97
|
+
userId?: string;
|
|
98
|
+
from?: string;
|
|
99
|
+
subject?: string;
|
|
100
|
+
replyTo?: string;
|
|
101
|
+
category?: string; // a LIST id to gate the send on (see hogsend-authoring-lists)
|
|
102
|
+
skipPreferenceCheck?: boolean;// bypass unsub/suppression — needs full-admin
|
|
103
|
+
idempotencyKey?: string;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
interface SendEmailResult {
|
|
107
|
+
emailSendId: string;
|
|
108
|
+
status: string; // queued | sent | suppressed | unsubscribed | skipped
|
|
109
|
+
reason?: string;
|
|
110
|
+
}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
**Typed templates.** When `@hogsend/email` is installed (a TYPE-ONLY optional
|
|
114
|
+
peer) and you augment its `TemplateRegistryMap`, `template` and `props` are
|
|
115
|
+
fully type-checked per known template key. Without it, the shape degrades to
|
|
116
|
+
`{ template: string; props?: Record<string, unknown> }`.
|
|
117
|
+
|
|
118
|
+
> tsconfig caveat: the shipped `.d.ts` references `@hogsend/email` by module
|
|
119
|
+
> name. If you do NOT install that optional peer, keep `skipLibCheck: true` (the
|
|
120
|
+
> scaffold default) or `tsc` emits `TS2307: Cannot find module '@hogsend/email'`
|
|
121
|
+
> from this package's declarations. Installing the peer (even type-only) removes
|
|
122
|
+
> the caveat. The runtime JS has no dependency on `@hogsend/email`.
|
|
123
|
+
|
|
124
|
+
- `category` gates the send on a code-defined list's opt-in/opt-out polarity —
|
|
125
|
+
see the hogsend-authoring-lists skill.
|
|
126
|
+
- `skipPreferenceCheck: true` bypasses the unsubscribe/suppression gate and
|
|
127
|
+
requires a `full-admin` key, not a plain `ingest` key.
|
|
128
|
+
|
|
129
|
+
## `hs.lists`
|
|
130
|
+
|
|
131
|
+
### `lists.list() → ListSummary[]`
|
|
132
|
+
|
|
133
|
+
`GET /v1/lists`. Every code-defined list in the app.
|
|
134
|
+
|
|
135
|
+
```ts
|
|
136
|
+
interface ListSummary {
|
|
137
|
+
id: string;
|
|
138
|
+
name: string;
|
|
139
|
+
description?: string;
|
|
140
|
+
defaultOptIn: boolean;
|
|
141
|
+
}
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
### `lists.subscribe(input) → { subscribed: true }`
|
|
145
|
+
|
|
146
|
+
`POST /v1/lists/:id/subscribe`. Sets `categories[id] = true` for the identity.
|
|
147
|
+
|
|
148
|
+
### `lists.unsubscribe(input) → { unsubscribed: true }`
|
|
149
|
+
|
|
150
|
+
`POST /v1/lists/:id/unsubscribe`. Sets `categories[id] = false` for the identity.
|
|
151
|
+
|
|
152
|
+
```ts
|
|
153
|
+
type SubscribeInput = Identity & { list: string };
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
For how `subscribe`/`unsubscribe` interact with a list's `defaultOptIn` polarity
|
|
157
|
+
(opt-in needs an exact `true`, opt-out is blocked only on an exact `false`), see
|
|
158
|
+
the hogsend-authoring-lists skill.
|
|
159
|
+
|
|
160
|
+
## Construction options (recap)
|
|
161
|
+
|
|
162
|
+
```ts
|
|
163
|
+
interface HogsendOptions {
|
|
164
|
+
baseUrl: string; // required — e.g. https://api.example.com
|
|
165
|
+
apiKey: string; // required — hsk_… key with the `ingest` scope
|
|
166
|
+
fetch?: typeof fetch; // default: global fetch
|
|
167
|
+
timeoutMs?: number; // default: 30000 (aborts the request)
|
|
168
|
+
headers?: Record<string, string>; // merged onto every request
|
|
169
|
+
}
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
## Errors (recap)
|
|
173
|
+
|
|
174
|
+
- `HogsendAPIError` — `{ status, body }`. `status === 0` = transport failure
|
|
175
|
+
(DNS/connect/timeout); `body` is parsed JSON or raw text.
|
|
176
|
+
- `RateLimitError extends HogsendAPIError` — `status === 429`, with `retryAfter`
|
|
177
|
+
(seconds) from the `Retry-After` header when present.
|
|
178
|
+
|
|
179
|
+
Both are exported from `@hogsend/client` and are real classes, so
|
|
180
|
+
`err instanceof RateLimitError` / `err instanceof HogsendAPIError` narrows
|
|
181
|
+
correctly (check `RateLimitError` first — it is the subclass).
|
package/src/bin.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { createRequire } from "node:module";
|
|
|
3
3
|
import { commands } from "./commands/index.js";
|
|
4
4
|
import type { Command } from "./commands/types.js";
|
|
5
5
|
import { parseGlobalFlags, resolveConfig } from "./lib/config.js";
|
|
6
|
-
import { createAdminClient } from "./lib/http.js";
|
|
6
|
+
import { createAdminClient, createDataPlaneClient } from "./lib/http.js";
|
|
7
7
|
import { color, createOutput } from "./lib/output.js";
|
|
8
8
|
|
|
9
9
|
function version(): string {
|
|
@@ -31,6 +31,7 @@ ${list}
|
|
|
31
31
|
${color.dim("Global options:")}
|
|
32
32
|
--url <baseUrl> Target instance (default HOGSEND_API_URL or http://localhost:3002)
|
|
33
33
|
--admin-key <key> Admin bearer token (default HOGSEND_ADMIN_KEY / ADMIN_API_KEY)
|
|
34
|
+
--data-key <key> Ingest bearer token for writes (default HOGSEND_DATA_KEY / HOGSEND_API_KEY)
|
|
34
35
|
--json Emit machine-readable JSON only (for agents)
|
|
35
36
|
-h, --help Show help (use after a command for command help)
|
|
36
37
|
-v, --version Show version
|
|
@@ -80,11 +81,13 @@ async function main(): Promise<void> {
|
|
|
80
81
|
|
|
81
82
|
const cfg = resolveConfig(flags);
|
|
82
83
|
const http = createAdminClient(cfg);
|
|
84
|
+
const dataHttp = createDataPlaneClient(cfg);
|
|
83
85
|
|
|
84
86
|
await command.run({
|
|
85
87
|
argv: flags.rest,
|
|
86
88
|
cfg,
|
|
87
89
|
http,
|
|
90
|
+
dataHttp,
|
|
88
91
|
out,
|
|
89
92
|
json: flags.json,
|
|
90
93
|
});
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import { parseArgs } from "node:util";
|
|
2
|
+
import { isHttpError } from "../lib/http.js";
|
|
3
|
+
import { color } from "../lib/output.js";
|
|
4
|
+
import type { Command, CommandContext } from "./types.js";
|
|
5
|
+
|
|
6
|
+
const usage = `hogsend campaigns <subcommand> [options]
|
|
7
|
+
|
|
8
|
+
Queue and inspect broadcasts: durably send one email template to every
|
|
9
|
+
subscribed member of a list (or every active member of a bucket). Wraps the
|
|
10
|
+
data-plane campaigns routes (POST /v1/campaigns, GET /v1/campaigns/{id}).
|
|
11
|
+
|
|
12
|
+
Subcommands:
|
|
13
|
+
send Queue a campaign. Sends run async in the worker.
|
|
14
|
+
status <id> Show a campaign's status + send counts.
|
|
15
|
+
|
|
16
|
+
send options (exactly one of --list / --bucket, plus --template, required):
|
|
17
|
+
--list <id> Target every subscribed member of this list.
|
|
18
|
+
--bucket <id> Target every active member of this bucket.
|
|
19
|
+
--template <key> Email template to send.
|
|
20
|
+
--prop <key=value> Template prop; repeatable. Value parsed as JSON, falling
|
|
21
|
+
back to a string.
|
|
22
|
+
--props <json> Template props as one JSON object (merged with --prop).
|
|
23
|
+
--name <text> Human label for the campaign.
|
|
24
|
+
--from <addr> Override the default From address.
|
|
25
|
+
--subject <text> Override the rendered subject.
|
|
26
|
+
|
|
27
|
+
Global options (handled by the router): --url, --admin-key, --data-key, --json,
|
|
28
|
+
-h/--help.
|
|
29
|
+
|
|
30
|
+
Examples:
|
|
31
|
+
hogsend campaigns send --list newsletter --template june-update --name "June"
|
|
32
|
+
hogsend campaigns send --bucket power-users --template feature-launch --json
|
|
33
|
+
hogsend campaigns status cmp_123 --json`;
|
|
34
|
+
|
|
35
|
+
const badge = `${color.bgMagenta(color.black(" hogsend "))} campaigns`;
|
|
36
|
+
|
|
37
|
+
/** Shape returned by POST /v1/campaigns (202 enqueue ack). */
|
|
38
|
+
interface SendResponse {
|
|
39
|
+
campaignId: string;
|
|
40
|
+
status: "queued" | "sending" | "sent" | "failed";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Shape returned by GET /v1/campaigns/{id}. */
|
|
44
|
+
interface StatusResponse {
|
|
45
|
+
id: string;
|
|
46
|
+
name: string;
|
|
47
|
+
status: "queued" | "sending" | "sent" | "failed";
|
|
48
|
+
audienceKind: "list" | "bucket";
|
|
49
|
+
audienceId: string;
|
|
50
|
+
templateKey: string;
|
|
51
|
+
totalRecipients: number;
|
|
52
|
+
sentCount: number;
|
|
53
|
+
skippedCount: number;
|
|
54
|
+
failedCount: number;
|
|
55
|
+
startedAt: string | null;
|
|
56
|
+
completedAt: string | null;
|
|
57
|
+
createdAt: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Parse `--prop key=value` (repeatable) + an optional `--props <json>` object
|
|
62
|
+
* into a single props record. Each `--prop` value is JSON-parsed when valid
|
|
63
|
+
* JSON, else kept as a string. The `--props` object is applied first, so later
|
|
64
|
+
* `--prop` flags win.
|
|
65
|
+
*/
|
|
66
|
+
function parseProps(
|
|
67
|
+
ctx: CommandContext,
|
|
68
|
+
propsJson: string | undefined,
|
|
69
|
+
propPairs: string[] | undefined,
|
|
70
|
+
): Record<string, unknown> | undefined {
|
|
71
|
+
const out: Record<string, unknown> = {};
|
|
72
|
+
let any = false;
|
|
73
|
+
|
|
74
|
+
if (propsJson !== undefined) {
|
|
75
|
+
let parsed: unknown;
|
|
76
|
+
try {
|
|
77
|
+
parsed = JSON.parse(propsJson);
|
|
78
|
+
} catch {
|
|
79
|
+
ctx.out.fail(`--props must be valid JSON, got: ${propsJson}`);
|
|
80
|
+
}
|
|
81
|
+
if (
|
|
82
|
+
parsed === null ||
|
|
83
|
+
typeof parsed !== "object" ||
|
|
84
|
+
Array.isArray(parsed)
|
|
85
|
+
) {
|
|
86
|
+
ctx.out.fail("--props must be a JSON object");
|
|
87
|
+
}
|
|
88
|
+
Object.assign(out, parsed as Record<string, unknown>);
|
|
89
|
+
any = true;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
for (const pair of propPairs ?? []) {
|
|
93
|
+
const eq = pair.indexOf("=");
|
|
94
|
+
if (eq === -1) {
|
|
95
|
+
ctx.out.fail(`--prop must be key=value, got: ${pair}`);
|
|
96
|
+
}
|
|
97
|
+
const key = pair.slice(0, eq).trim();
|
|
98
|
+
if (key === "") {
|
|
99
|
+
ctx.out.fail(`--prop key cannot be empty, got: ${pair}`);
|
|
100
|
+
}
|
|
101
|
+
out[key] = coerceValue(pair.slice(eq + 1));
|
|
102
|
+
any = true;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return any ? out : undefined;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** JSON-parse a flag value, falling back to the raw string. */
|
|
109
|
+
function coerceValue(raw: string): unknown {
|
|
110
|
+
try {
|
|
111
|
+
return JSON.parse(raw);
|
|
112
|
+
} catch {
|
|
113
|
+
return raw;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function statusColor(status: SendResponse["status"]): string {
|
|
118
|
+
switch (status) {
|
|
119
|
+
case "sent":
|
|
120
|
+
return color.green(status);
|
|
121
|
+
case "queued":
|
|
122
|
+
case "sending":
|
|
123
|
+
return color.cyan(status);
|
|
124
|
+
default:
|
|
125
|
+
// failed
|
|
126
|
+
return color.red(status);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function runSend(ctx: CommandContext, argv: string[]): Promise<void> {
|
|
131
|
+
const { values } = parseArgs({
|
|
132
|
+
args: argv,
|
|
133
|
+
allowPositionals: true,
|
|
134
|
+
options: {
|
|
135
|
+
list: { type: "string" },
|
|
136
|
+
bucket: { type: "string" },
|
|
137
|
+
template: { type: "string" },
|
|
138
|
+
prop: { type: "string", multiple: true },
|
|
139
|
+
props: { type: "string" },
|
|
140
|
+
name: { type: "string" },
|
|
141
|
+
from: { type: "string" },
|
|
142
|
+
subject: { type: "string" },
|
|
143
|
+
help: { type: "boolean", short: "h", default: false },
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
if (values.help) {
|
|
148
|
+
ctx.out.log(usage);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const list = values.list;
|
|
153
|
+
const bucket = values.bucket;
|
|
154
|
+
if ((list && bucket) || (!list && !bucket)) {
|
|
155
|
+
ctx.out.fail("campaigns send requires exactly one of --list or --bucket");
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const template = values.template;
|
|
159
|
+
if (!template) {
|
|
160
|
+
ctx.out.fail(
|
|
161
|
+
"campaigns send requires --template, e.g. hogsend campaigns send --list newsletter --template welcome",
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const props = parseProps(ctx, values.props, values.prop);
|
|
166
|
+
|
|
167
|
+
const body: {
|
|
168
|
+
template: string;
|
|
169
|
+
list?: string;
|
|
170
|
+
bucket?: string;
|
|
171
|
+
props?: Record<string, unknown>;
|
|
172
|
+
name?: string;
|
|
173
|
+
from?: string;
|
|
174
|
+
subject?: string;
|
|
175
|
+
} = { template };
|
|
176
|
+
if (list) body.list = list;
|
|
177
|
+
if (bucket) body.bucket = bucket;
|
|
178
|
+
if (props) body.props = props;
|
|
179
|
+
if (values.name) body.name = values.name;
|
|
180
|
+
if (values.from) body.from = values.from;
|
|
181
|
+
if (values.subject) body.subject = values.subject;
|
|
182
|
+
|
|
183
|
+
let res: SendResponse;
|
|
184
|
+
try {
|
|
185
|
+
res = await ctx.out.step(`Queuing campaign ${template}`, () =>
|
|
186
|
+
ctx.dataHttp.post<SendResponse>("/v1/campaigns", body),
|
|
187
|
+
);
|
|
188
|
+
} catch (error) {
|
|
189
|
+
if (isHttpError(error)) {
|
|
190
|
+
ctx.out.fail(error.message);
|
|
191
|
+
}
|
|
192
|
+
throw error;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (ctx.json) {
|
|
196
|
+
ctx.out.json(res);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
ctx.out.intro(`${badge} send`);
|
|
201
|
+
ctx.out.kv(
|
|
202
|
+
{
|
|
203
|
+
campaignId: res.campaignId,
|
|
204
|
+
template,
|
|
205
|
+
audience: list ? `list:${list}` : `bucket:${bucket}`,
|
|
206
|
+
status: statusColor(res.status),
|
|
207
|
+
},
|
|
208
|
+
"Campaign queued",
|
|
209
|
+
);
|
|
210
|
+
ctx.out.outro(
|
|
211
|
+
`${color.green("Queued")} — poll ${color.cyan(`hogsend campaigns status ${res.campaignId}`)} for progress.`,
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function runStatus(ctx: CommandContext, argv: string[]): Promise<void> {
|
|
216
|
+
const { values, positionals } = parseArgs({
|
|
217
|
+
args: argv,
|
|
218
|
+
allowPositionals: true,
|
|
219
|
+
options: {
|
|
220
|
+
help: { type: "boolean", short: "h", default: false },
|
|
221
|
+
},
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
if (values.help) {
|
|
225
|
+
ctx.out.log(usage);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const id = positionals[0];
|
|
230
|
+
if (!id) {
|
|
231
|
+
ctx.out.fail(
|
|
232
|
+
"campaigns status requires a campaign id, e.g. hogsend campaigns status cmp_123",
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
let res: StatusResponse;
|
|
237
|
+
try {
|
|
238
|
+
res = await ctx.out.step(`Fetching campaign ${id}`, () =>
|
|
239
|
+
ctx.dataHttp.get<StatusResponse>(
|
|
240
|
+
`/v1/campaigns/${encodeURIComponent(id)}`,
|
|
241
|
+
),
|
|
242
|
+
);
|
|
243
|
+
} catch (error) {
|
|
244
|
+
if (isHttpError(error)) {
|
|
245
|
+
ctx.out.fail(error.message);
|
|
246
|
+
}
|
|
247
|
+
throw error;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (ctx.json) {
|
|
251
|
+
ctx.out.json(res);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
ctx.out.intro(`${badge} status`);
|
|
256
|
+
ctx.out.kv(
|
|
257
|
+
{
|
|
258
|
+
id: res.id,
|
|
259
|
+
name: res.name,
|
|
260
|
+
status: statusColor(res.status),
|
|
261
|
+
audience: `${res.audienceKind}:${res.audienceId}`,
|
|
262
|
+
template: res.templateKey,
|
|
263
|
+
recipients: res.totalRecipients,
|
|
264
|
+
sent: color.green(String(res.sentCount)),
|
|
265
|
+
skipped: color.yellow(String(res.skippedCount)),
|
|
266
|
+
failed:
|
|
267
|
+
res.failedCount > 0
|
|
268
|
+
? color.red(String(res.failedCount))
|
|
269
|
+
: String(res.failedCount),
|
|
270
|
+
startedAt: res.startedAt ?? "",
|
|
271
|
+
completedAt: res.completedAt ?? "",
|
|
272
|
+
},
|
|
273
|
+
"Campaign",
|
|
274
|
+
);
|
|
275
|
+
ctx.out.outro(
|
|
276
|
+
`${res.name} → ${statusColor(res.status)} ` +
|
|
277
|
+
color.dim(
|
|
278
|
+
`(${res.sentCount}/${res.totalRecipients} sent, ${res.skippedCount} skipped, ${res.failedCount} failed)`,
|
|
279
|
+
),
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async function run(ctx: CommandContext): Promise<void> {
|
|
284
|
+
const sub = ctx.argv[0];
|
|
285
|
+
|
|
286
|
+
switch (sub) {
|
|
287
|
+
case "send":
|
|
288
|
+
// Strip the leading "send" token; the rest is send's own args.
|
|
289
|
+
return runSend(ctx, ctx.argv.slice(1));
|
|
290
|
+
case "status":
|
|
291
|
+
return runStatus(ctx, ctx.argv.slice(1));
|
|
292
|
+
case undefined:
|
|
293
|
+
ctx.out.fail(
|
|
294
|
+
"campaigns requires a subcommand: send | status (see hogsend campaigns --help)",
|
|
295
|
+
);
|
|
296
|
+
break;
|
|
297
|
+
default:
|
|
298
|
+
ctx.out.fail(
|
|
299
|
+
`unknown campaigns subcommand "${sub}" — expected send or status`,
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export const campaignsCommand: Command = {
|
|
305
|
+
name: "campaigns",
|
|
306
|
+
summary: "Queue a broadcast to a list/bucket, or check its status",
|
|
307
|
+
usage,
|
|
308
|
+
run,
|
|
309
|
+
};
|