@hogsend/cli 0.2.3 → 0.7.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-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-r9qr4mus.js → index-D7Ax_oFF.js} +1 -1
- package/studio/index.html +1 -1
package/src/commands/events.ts
CHANGED
|
@@ -4,26 +4,51 @@ import { color } from "../lib/output.js";
|
|
|
4
4
|
import type { Command, CommandContext } from "./types.js";
|
|
5
5
|
|
|
6
6
|
const usage = `hogsend events <userId> [options]
|
|
7
|
+
hogsend events send <name> [options]
|
|
7
8
|
|
|
8
|
-
|
|
9
|
-
|
|
9
|
+
Read a single user's event history (admin API), or send an event into the data
|
|
10
|
+
plane to drive journeys/buckets.
|
|
10
11
|
|
|
11
|
-
|
|
12
|
-
|
|
12
|
+
Read mode — hogsend events <userId>:
|
|
13
|
+
Stream the event history for a single user, newest first. Wraps
|
|
14
|
+
GET /v1/admin/events?userId=<userId>.
|
|
13
15
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
16
|
+
Arguments:
|
|
17
|
+
<userId> The user (distinct) id to fetch events for. Required.
|
|
18
|
+
|
|
19
|
+
Options:
|
|
20
|
+
--event <name> Filter to a single event name.
|
|
21
|
+
--from <iso> Only events at/after this ISO-8601 timestamp.
|
|
22
|
+
--to <iso> Only events at/before this ISO-8601 timestamp.
|
|
23
|
+
--limit <n> Max events to return (1-100, default 50).
|
|
24
|
+
--offset <n> Pagination offset (default 0).
|
|
25
|
+
|
|
26
|
+
Send mode — hogsend events send <name>:
|
|
27
|
+
Push an event into POST /v1/events (data plane, ingest key). At least one of
|
|
28
|
+
--email / --user-id is required.
|
|
29
|
+
|
|
30
|
+
Options:
|
|
31
|
+
--email <addr> Recipient/identity email.
|
|
32
|
+
--user-id <id> External (distinct) id.
|
|
33
|
+
--prop <key=value> Event property; repeatable. Value parsed as JSON,
|
|
34
|
+
falling back to a string.
|
|
35
|
+
--props <json> Event properties as one JSON object.
|
|
36
|
+
--contact-prop <k=v> Contact property to merge onto the contact; repeatable.
|
|
37
|
+
--contact-props <json> Contact properties as one JSON object.
|
|
38
|
+
--list <id> Subscribe to a list; repeatable.
|
|
39
|
+
--unlist <id> Unsubscribe from a list; repeatable.
|
|
40
|
+
--idempotency-key <k> Dedup key (sent as the Idempotency-Key header).
|
|
41
|
+
--timestamp <iso> Override the event timestamp.
|
|
42
|
+
|
|
43
|
+
Global options (handled by the router): --url, --admin-key, --data-key, --json,
|
|
44
|
+
-h/--help.
|
|
22
45
|
|
|
23
46
|
Examples:
|
|
24
47
|
hogsend events user_123
|
|
25
48
|
hogsend events user_123 --event signup --limit 10
|
|
26
|
-
hogsend events user_123 --from 2026-01-01T00:00:00Z --json
|
|
49
|
+
hogsend events user_123 --from 2026-01-01T00:00:00Z --json
|
|
50
|
+
hogsend events send signup --user-id user_123 --prop plan=pro
|
|
51
|
+
hogsend events send purchase --email a@b.com --props '{"amount":49}' --json`;
|
|
27
52
|
|
|
28
53
|
interface UserEvent {
|
|
29
54
|
id: string;
|
|
@@ -40,9 +65,31 @@ interface EventsResponse {
|
|
|
40
65
|
offset: number;
|
|
41
66
|
}
|
|
42
67
|
|
|
68
|
+
/** Shape returned by POST /v1/events. */
|
|
69
|
+
interface ExitResult {
|
|
70
|
+
journeyId: string;
|
|
71
|
+
stateId: string;
|
|
72
|
+
exited: boolean;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface SendResponse {
|
|
76
|
+
stored: boolean;
|
|
77
|
+
exits: ExitResult[];
|
|
78
|
+
}
|
|
79
|
+
|
|
43
80
|
async function run(ctx: CommandContext): Promise<void> {
|
|
81
|
+
// `events send <name>` is the write path; everything else is the read path
|
|
82
|
+
// (bare `events <userId>`). Dispatch on the first positional WITHOUT a global
|
|
83
|
+
// --help short-circuit here, so `events send --help` still shows usage.
|
|
84
|
+
if (ctx.argv[0] === "send") {
|
|
85
|
+
return runSend(ctx, ctx.argv.slice(1));
|
|
86
|
+
}
|
|
87
|
+
return runRead(ctx, ctx.argv);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function runRead(ctx: CommandContext, argv: string[]): Promise<void> {
|
|
44
91
|
const { values, positionals } = parseArgs({
|
|
45
|
-
args:
|
|
92
|
+
args: argv,
|
|
46
93
|
allowPositionals: true,
|
|
47
94
|
options: {
|
|
48
95
|
event: { type: "string" },
|
|
@@ -120,6 +167,197 @@ async function run(ctx: CommandContext): Promise<void> {
|
|
|
120
167
|
);
|
|
121
168
|
}
|
|
122
169
|
|
|
170
|
+
async function runSend(ctx: CommandContext, argv: string[]): Promise<void> {
|
|
171
|
+
const { values, positionals } = parseArgs({
|
|
172
|
+
args: argv,
|
|
173
|
+
allowPositionals: true,
|
|
174
|
+
options: {
|
|
175
|
+
email: { type: "string" },
|
|
176
|
+
"user-id": { type: "string" },
|
|
177
|
+
prop: { type: "string", multiple: true },
|
|
178
|
+
props: { type: "string" },
|
|
179
|
+
"contact-prop": { type: "string", multiple: true },
|
|
180
|
+
"contact-props": { type: "string" },
|
|
181
|
+
list: { type: "string", multiple: true },
|
|
182
|
+
unlist: { type: "string", multiple: true },
|
|
183
|
+
"idempotency-key": { type: "string" },
|
|
184
|
+
timestamp: { type: "string" },
|
|
185
|
+
help: { type: "boolean", short: "h", default: false },
|
|
186
|
+
},
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
if (values.help) {
|
|
190
|
+
ctx.out.log(usage);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// positionals[0] is the event name (the "send" token was already stripped).
|
|
195
|
+
const name = positionals[0];
|
|
196
|
+
if (!name) {
|
|
197
|
+
ctx.out.fail(
|
|
198
|
+
"events send requires an event name, e.g. hogsend events send signup --user-id user_123",
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const email = values.email;
|
|
203
|
+
const userId = values["user-id"];
|
|
204
|
+
if (!email && !userId) {
|
|
205
|
+
ctx.out.fail("events send requires at least one of --email or --user-id");
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const eventProperties = parseProps(ctx, values.props, values.prop, "prop");
|
|
209
|
+
const contactProperties = parseProps(
|
|
210
|
+
ctx,
|
|
211
|
+
values["contact-props"],
|
|
212
|
+
values["contact-prop"],
|
|
213
|
+
"contact-prop",
|
|
214
|
+
);
|
|
215
|
+
const lists = parseLists(values.list, values.unlist);
|
|
216
|
+
|
|
217
|
+
const body: {
|
|
218
|
+
name: string;
|
|
219
|
+
email?: string;
|
|
220
|
+
userId?: string;
|
|
221
|
+
eventProperties?: Record<string, unknown>;
|
|
222
|
+
contactProperties?: Record<string, unknown>;
|
|
223
|
+
lists?: Record<string, boolean>;
|
|
224
|
+
idempotencyKey?: string;
|
|
225
|
+
timestamp?: string;
|
|
226
|
+
} = { name };
|
|
227
|
+
if (email) body.email = email;
|
|
228
|
+
if (userId) body.userId = userId;
|
|
229
|
+
if (eventProperties) body.eventProperties = eventProperties;
|
|
230
|
+
if (contactProperties) body.contactProperties = contactProperties;
|
|
231
|
+
if (lists) body.lists = lists;
|
|
232
|
+
if (values["idempotency-key"]) {
|
|
233
|
+
body.idempotencyKey = values["idempotency-key"];
|
|
234
|
+
}
|
|
235
|
+
if (values.timestamp) body.timestamp = values.timestamp;
|
|
236
|
+
|
|
237
|
+
let res: SendResponse;
|
|
238
|
+
try {
|
|
239
|
+
res = await ctx.out.step(`Sending event ${name}`, () =>
|
|
240
|
+
ctx.dataHttp.post<SendResponse>("/v1/events", body),
|
|
241
|
+
);
|
|
242
|
+
} catch (error) {
|
|
243
|
+
if (isHttpError(error)) {
|
|
244
|
+
ctx.out.fail(error.message);
|
|
245
|
+
}
|
|
246
|
+
throw error;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (ctx.json) {
|
|
250
|
+
ctx.out.json(res);
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
ctx.out.intro(`${color.bgMagenta(color.black(" hogsend "))} events send`);
|
|
255
|
+
|
|
256
|
+
const exited = res.exits.filter((e) => e.exited);
|
|
257
|
+
ctx.out.kv(
|
|
258
|
+
{
|
|
259
|
+
event: name,
|
|
260
|
+
stored: res.stored,
|
|
261
|
+
identity: email ?? userId ?? "",
|
|
262
|
+
exits: res.exits.length,
|
|
263
|
+
"journeys exited": exited.length,
|
|
264
|
+
},
|
|
265
|
+
"Event sent",
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
if (exited.length > 0) {
|
|
269
|
+
ctx.out.table(
|
|
270
|
+
exited.map((e) => ({ journeyId: e.journeyId, stateId: e.stateId })),
|
|
271
|
+
["journeyId", "stateId"],
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
ctx.out.outro(
|
|
276
|
+
res.stored
|
|
277
|
+
? `${color.green("Stored")} ${name}.`
|
|
278
|
+
: color.dim(`${name} was deduped (not stored).`),
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Parse `--<flag> key=value` (repeatable) + an optional `--<flag>s <json>`
|
|
284
|
+
* object into a single properties record. Each value is JSON-parsed when valid
|
|
285
|
+
* JSON, else kept as a string. The JSON object is applied first so later
|
|
286
|
+
* key=value flags win. `flagName` is used only for error messages.
|
|
287
|
+
*/
|
|
288
|
+
function parseProps(
|
|
289
|
+
ctx: CommandContext,
|
|
290
|
+
json: string | undefined,
|
|
291
|
+
pairs: string[] | undefined,
|
|
292
|
+
flagName: string,
|
|
293
|
+
): Record<string, unknown> | undefined {
|
|
294
|
+
const out: Record<string, unknown> = {};
|
|
295
|
+
let any = false;
|
|
296
|
+
|
|
297
|
+
if (json !== undefined) {
|
|
298
|
+
let parsed: unknown;
|
|
299
|
+
try {
|
|
300
|
+
parsed = JSON.parse(json);
|
|
301
|
+
} catch {
|
|
302
|
+
ctx.out.fail(`--${flagName}s must be valid JSON, got: ${json}`);
|
|
303
|
+
}
|
|
304
|
+
if (
|
|
305
|
+
parsed === null ||
|
|
306
|
+
typeof parsed !== "object" ||
|
|
307
|
+
Array.isArray(parsed)
|
|
308
|
+
) {
|
|
309
|
+
ctx.out.fail(`--${flagName}s must be a JSON object`);
|
|
310
|
+
}
|
|
311
|
+
Object.assign(out, parsed as Record<string, unknown>);
|
|
312
|
+
any = true;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
for (const pair of pairs ?? []) {
|
|
316
|
+
const eq = pair.indexOf("=");
|
|
317
|
+
if (eq === -1) {
|
|
318
|
+
ctx.out.fail(`--${flagName} must be key=value, got: ${pair}`);
|
|
319
|
+
}
|
|
320
|
+
const key = pair.slice(0, eq).trim();
|
|
321
|
+
if (key === "") {
|
|
322
|
+
ctx.out.fail(`--${flagName} key cannot be empty, got: ${pair}`);
|
|
323
|
+
}
|
|
324
|
+
out[key] = coerceValue(pair.slice(eq + 1));
|
|
325
|
+
any = true;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return any ? out : undefined;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** JSON-parse a flag value, falling back to the raw string. */
|
|
332
|
+
function coerceValue(raw: string): unknown {
|
|
333
|
+
try {
|
|
334
|
+
return JSON.parse(raw);
|
|
335
|
+
} catch {
|
|
336
|
+
return raw;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Build a `lists` map from repeatable `--list <id>` (true) / `--unlist <id>`
|
|
342
|
+
* (false) flags. Returns undefined when neither was passed.
|
|
343
|
+
*/
|
|
344
|
+
function parseLists(
|
|
345
|
+
subscribe: string[] | undefined,
|
|
346
|
+
unsubscribe: string[] | undefined,
|
|
347
|
+
): Record<string, boolean> | undefined {
|
|
348
|
+
const out: Record<string, boolean> = {};
|
|
349
|
+
let any = false;
|
|
350
|
+
for (const id of subscribe ?? []) {
|
|
351
|
+
out[id] = true;
|
|
352
|
+
any = true;
|
|
353
|
+
}
|
|
354
|
+
for (const id of unsubscribe ?? []) {
|
|
355
|
+
out[id] = false;
|
|
356
|
+
any = true;
|
|
357
|
+
}
|
|
358
|
+
return any ? out : undefined;
|
|
359
|
+
}
|
|
360
|
+
|
|
123
361
|
/**
|
|
124
362
|
* Parse an optional numeric flag. Returns undefined when absent (lets the
|
|
125
363
|
* server apply its default); fails on a non-numeric value.
|
|
@@ -148,7 +386,7 @@ function summarizeProps(props: Record<string, unknown> | null): string {
|
|
|
148
386
|
|
|
149
387
|
export const eventsCommand: Command = {
|
|
150
388
|
name: "events",
|
|
151
|
-
summary: "Stream a
|
|
389
|
+
summary: "Stream a user's event history, or send an event",
|
|
152
390
|
usage,
|
|
153
391
|
run,
|
|
154
392
|
};
|
package/src/commands/index.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { campaignsCommand } from "./campaigns.js";
|
|
1
2
|
import { contactsCommand } from "./contacts.js";
|
|
2
3
|
import { doctorCommand } from "./doctor.js";
|
|
3
4
|
import { ejectCommand } from "./eject.js";
|
|
5
|
+
import { emailsCommand } from "./emails.js";
|
|
4
6
|
import { eventsCommand } from "./events.js";
|
|
5
7
|
import { journeysCommand } from "./journeys.js";
|
|
6
8
|
import { patchCommand } from "./patch.js";
|
|
@@ -25,6 +27,8 @@ export const commands: Command[] = [
|
|
|
25
27
|
contactsCommand,
|
|
26
28
|
statsCommand,
|
|
27
29
|
eventsCommand,
|
|
30
|
+
emailsCommand,
|
|
31
|
+
campaignsCommand,
|
|
28
32
|
studioCommand,
|
|
29
33
|
setupCommand,
|
|
30
34
|
skillsCommand,
|
package/src/commands/types.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ResolvedConfig } from "../lib/config.js";
|
|
2
|
-
import type { AdminClient } from "../lib/http.js";
|
|
2
|
+
import type { AdminClient, DataPlaneClient } from "../lib/http.js";
|
|
3
3
|
import type { Output } from "../lib/output.js";
|
|
4
4
|
|
|
5
5
|
/**
|
|
@@ -17,8 +17,14 @@ export interface CommandContext {
|
|
|
17
17
|
argv: string[];
|
|
18
18
|
/** Base URL + admin key, already resolved via flags > env > .env. */
|
|
19
19
|
cfg: ResolvedConfig;
|
|
20
|
-
/** Pre-built admin HTTP client, bound to `cfg
|
|
20
|
+
/** Pre-built admin HTTP client, bound to `cfg` (admin key). */
|
|
21
21
|
http: AdminClient;
|
|
22
|
+
/**
|
|
23
|
+
* Pre-built data-plane HTTP client, bound to `cfg.dataKey` (ingest key). Used
|
|
24
|
+
* by the write commands (`contacts upsert`, `events send`, `emails send`)
|
|
25
|
+
* that hit the authed `/v1/contacts`, `/v1/events`, `/v1/emails` routes.
|
|
26
|
+
*/
|
|
27
|
+
dataHttp: DataPlaneClient;
|
|
22
28
|
/** Output sink — human (TTY clack) vs json, already mode-selected. */
|
|
23
29
|
out: Output;
|
|
24
30
|
/** True when the global `--json` flag was passed. Mirrors `out.isJson`. */
|
package/src/lib/config.ts
CHANGED
|
@@ -8,12 +8,19 @@ export interface ResolvedConfig {
|
|
|
8
8
|
baseUrl: string;
|
|
9
9
|
/** Admin bearer token, if resolvable. `doctor`/health works without it. */
|
|
10
10
|
adminKey: string | undefined;
|
|
11
|
+
/**
|
|
12
|
+
* Data-plane bearer token (an `ingest`-scoped key), if resolvable. Used by the
|
|
13
|
+
* write commands (`contacts upsert`, `events send`, `emails send`). Falls back
|
|
14
|
+
* to the admin key since `full-admin` implies `ingest`.
|
|
15
|
+
*/
|
|
16
|
+
dataKey: string | undefined;
|
|
11
17
|
}
|
|
12
18
|
|
|
13
19
|
/** Global flags parsed off the front of any command's argv. */
|
|
14
20
|
export interface GlobalFlags {
|
|
15
21
|
url?: string;
|
|
16
22
|
adminKey?: string;
|
|
23
|
+
dataKey?: string;
|
|
17
24
|
json: boolean;
|
|
18
25
|
help: boolean;
|
|
19
26
|
/** The remaining args after global flags are stripped. */
|
|
@@ -39,6 +46,7 @@ export function parseGlobalFlags(argv: string[]): GlobalFlags {
|
|
|
39
46
|
options: {
|
|
40
47
|
url: { type: "string" },
|
|
41
48
|
"admin-key": { type: "string" },
|
|
49
|
+
"data-key": { type: "string" },
|
|
42
50
|
json: { type: "boolean", default: false },
|
|
43
51
|
help: { type: "boolean", short: "h", default: false },
|
|
44
52
|
},
|
|
@@ -47,7 +55,7 @@ export function parseGlobalFlags(argv: string[]): GlobalFlags {
|
|
|
47
55
|
// Rebuild `rest` from the token stream, dropping only the global flags we
|
|
48
56
|
// own (and their values). Everything else — positionals and unknown option
|
|
49
57
|
// tokens — is preserved verbatim for the command's own parser.
|
|
50
|
-
const owned = new Set(["url", "admin-key", "json", "help", "h"]);
|
|
58
|
+
const owned = new Set(["url", "admin-key", "data-key", "json", "help", "h"]);
|
|
51
59
|
const rest: string[] = [];
|
|
52
60
|
for (const token of tokens) {
|
|
53
61
|
if (token.kind === "positional") {
|
|
@@ -68,6 +76,8 @@ export function parseGlobalFlags(argv: string[]): GlobalFlags {
|
|
|
68
76
|
url: typeof values.url === "string" ? values.url : undefined,
|
|
69
77
|
adminKey:
|
|
70
78
|
typeof values["admin-key"] === "string" ? values["admin-key"] : undefined,
|
|
79
|
+
dataKey:
|
|
80
|
+
typeof values["data-key"] === "string" ? values["data-key"] : undefined,
|
|
71
81
|
json: values.json === true,
|
|
72
82
|
help: values.help === true,
|
|
73
83
|
rest,
|
|
@@ -120,6 +130,7 @@ export function loadDotEnv(
|
|
|
120
130
|
*
|
|
121
131
|
* baseUrl: --url > HOGSEND_API_URL (env) > HOGSEND_API_URL (.env) > localhost:3002
|
|
122
132
|
* adminKey: --admin-key > HOGSEND_ADMIN_KEY|ADMIN_API_KEY (env) > (.env equiv)
|
|
133
|
+
* dataKey: --data-key > HOGSEND_DATA_KEY > HOGSEND_API_KEY (env then .env)
|
|
123
134
|
*/
|
|
124
135
|
export function resolveConfig(
|
|
125
136
|
flags: GlobalFlags,
|
|
@@ -140,8 +151,19 @@ export function resolveConfig(
|
|
|
140
151
|
dotenv.HOGSEND_ADMIN_KEY ??
|
|
141
152
|
dotenv.ADMIN_API_KEY;
|
|
142
153
|
|
|
154
|
+
// Data-plane (ingest-scoped) key. Precedence is independent of adminKey:
|
|
155
|
+
// explicit data key first, then a dedicated env/.env var, then the generic
|
|
156
|
+
// HOGSEND_API_KEY (which a fresh scaffold mints as an ingest key).
|
|
157
|
+
const dataKey =
|
|
158
|
+
flags.dataKey ??
|
|
159
|
+
process.env.HOGSEND_DATA_KEY ??
|
|
160
|
+
process.env.HOGSEND_API_KEY ??
|
|
161
|
+
dotenv.HOGSEND_DATA_KEY ??
|
|
162
|
+
dotenv.HOGSEND_API_KEY;
|
|
163
|
+
|
|
143
164
|
return {
|
|
144
165
|
baseUrl: baseUrlRaw.replace(/\/+$/, ""),
|
|
145
166
|
adminKey: adminKey && adminKey.length > 0 ? adminKey : undefined,
|
|
167
|
+
dataKey: dataKey && dataKey.length > 0 ? dataKey : undefined,
|
|
146
168
|
};
|
|
147
169
|
}
|
package/src/lib/http.ts
CHANGED
|
@@ -77,68 +77,141 @@ function bodyMessage(status: number, body: unknown): string {
|
|
|
77
77
|
return `request failed with status ${status}`;
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
-
/**
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
if (opts.auth && !cfg.adminKey) {
|
|
88
|
-
throw makeHttpError(
|
|
89
|
-
"no admin key configured — pass --admin-key, or set HOGSEND_ADMIN_KEY / ADMIN_API_KEY",
|
|
90
|
-
0,
|
|
91
|
-
undefined,
|
|
92
|
-
);
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
const headers: Record<string, string> = { Accept: "application/json" };
|
|
96
|
-
if (opts.auth && cfg.adminKey) {
|
|
97
|
-
headers.Authorization = `Bearer ${cfg.adminKey}`;
|
|
98
|
-
}
|
|
99
|
-
if (opts.body !== undefined) {
|
|
100
|
-
headers["Content-Type"] = "application/json";
|
|
101
|
-
}
|
|
80
|
+
/** Internal options accepted by the shared {@link request} core. */
|
|
81
|
+
interface RequestOpts {
|
|
82
|
+
query?: Query;
|
|
83
|
+
body?: unknown;
|
|
84
|
+
/** When false, no Authorization header is sent (e.g. /v1/health). */
|
|
85
|
+
auth: boolean;
|
|
86
|
+
}
|
|
102
87
|
|
|
103
|
-
|
|
88
|
+
/**
|
|
89
|
+
* The single HTTP core, shared by the admin client and the data-plane client.
|
|
90
|
+
* Bound to a `baseUrl` + a bearer `key`; the only difference between the two
|
|
91
|
+
* clients is which key (and which "missing key" message) they carry. Sends
|
|
92
|
+
* `Authorization: Bearer <key>` when `auth` is set, parses JSON, and throws an
|
|
93
|
+
* {@link HttpError} on any non-2xx response (or transport failure).
|
|
94
|
+
*/
|
|
95
|
+
async function request<T>(
|
|
96
|
+
baseUrl: string,
|
|
97
|
+
key: string | undefined,
|
|
98
|
+
missingKeyMessage: string,
|
|
99
|
+
method: string,
|
|
100
|
+
path: string,
|
|
101
|
+
opts: RequestOpts,
|
|
102
|
+
): Promise<T> {
|
|
103
|
+
if (opts.auth && !key) {
|
|
104
|
+
throw makeHttpError(missingKeyMessage, 0, undefined);
|
|
105
|
+
}
|
|
104
106
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
} catch (cause) {
|
|
113
|
-
const msg = cause instanceof Error ? cause.message : String(cause);
|
|
114
|
-
throw makeHttpError(`cannot reach ${cfg.baseUrl} (${msg})`, 0, undefined);
|
|
115
|
-
}
|
|
107
|
+
const headers: Record<string, string> = { Accept: "application/json" };
|
|
108
|
+
if (opts.auth && key) {
|
|
109
|
+
headers.Authorization = `Bearer ${key}`;
|
|
110
|
+
}
|
|
111
|
+
if (opts.body !== undefined) {
|
|
112
|
+
headers["Content-Type"] = "application/json";
|
|
113
|
+
}
|
|
116
114
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
}
|
|
115
|
+
const url = buildUrl(baseUrl, path, opts.query);
|
|
116
|
+
|
|
117
|
+
let res: Response;
|
|
118
|
+
try {
|
|
119
|
+
res = await fetch(url, {
|
|
120
|
+
method,
|
|
121
|
+
headers,
|
|
122
|
+
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
|
|
123
|
+
});
|
|
124
|
+
} catch (cause) {
|
|
125
|
+
const msg = cause instanceof Error ? cause.message : String(cause);
|
|
126
|
+
throw makeHttpError(`cannot reach ${baseUrl} (${msg})`, 0, undefined);
|
|
127
|
+
}
|
|
126
128
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
+
const text = await res.text();
|
|
130
|
+
let parsed: unknown;
|
|
131
|
+
if (text.length > 0) {
|
|
132
|
+
try {
|
|
133
|
+
parsed = JSON.parse(text);
|
|
134
|
+
} catch {
|
|
135
|
+
parsed = text;
|
|
129
136
|
}
|
|
137
|
+
}
|
|
130
138
|
|
|
131
|
-
|
|
139
|
+
if (!res.ok) {
|
|
140
|
+
throw makeHttpError(bodyMessage(res.status, parsed), res.status, parsed);
|
|
132
141
|
}
|
|
133
142
|
|
|
143
|
+
return parsed as T;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Build an {@link AdminClient} bound to the given resolved config. */
|
|
147
|
+
export function createAdminClient(cfg: ResolvedConfig): AdminClient {
|
|
148
|
+
const missing =
|
|
149
|
+
"no admin key configured — pass --admin-key, or set HOGSEND_ADMIN_KEY / ADMIN_API_KEY";
|
|
134
150
|
return {
|
|
135
151
|
cfg,
|
|
136
152
|
get: <T>(path: string, query?: Query, extras?: RequestExtras) =>
|
|
137
|
-
request<T>("GET", path, {
|
|
153
|
+
request<T>(cfg.baseUrl, cfg.adminKey, missing, "GET", path, {
|
|
154
|
+
query,
|
|
155
|
+
auth: extras?.auth ?? true,
|
|
156
|
+
}),
|
|
138
157
|
patch: <T>(path: string, body: unknown) =>
|
|
139
|
-
request<T>("PATCH", path, {
|
|
158
|
+
request<T>(cfg.baseUrl, cfg.adminKey, missing, "PATCH", path, {
|
|
159
|
+
body,
|
|
160
|
+
auth: true,
|
|
161
|
+
}),
|
|
162
|
+
post: <T>(path: string, body: unknown) =>
|
|
163
|
+
request<T>(cfg.baseUrl, cfg.adminKey, missing, "POST", path, {
|
|
164
|
+
body,
|
|
165
|
+
auth: true,
|
|
166
|
+
}),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Thin data-plane HTTP client over the same core as {@link createAdminClient},
|
|
172
|
+
* but bound to `cfg.dataKey` (an `ingest`-scoped key). Used by the write
|
|
173
|
+
* commands (`contacts upsert`, `events send`, `emails send`) which hit the
|
|
174
|
+
* authed `/v1/contacts`, `/v1/events`, and `/v1/emails` data-plane routes.
|
|
175
|
+
*
|
|
176
|
+
* Exposes the full read/write verb set the data plane needs: `get`/`post`/
|
|
177
|
+
* `put`/`del`. Every call is authenticated (there is no unauthenticated
|
|
178
|
+
* data-plane route), so there is no `{ auth: false }` escape hatch here.
|
|
179
|
+
*/
|
|
180
|
+
export interface DataPlaneClient {
|
|
181
|
+
get<T = unknown>(path: string, query?: Query): Promise<T>;
|
|
182
|
+
post<T = unknown>(path: string, body: unknown): Promise<T>;
|
|
183
|
+
put<T = unknown>(path: string, body: unknown): Promise<T>;
|
|
184
|
+
del<T = unknown>(path: string, body?: unknown): Promise<T>;
|
|
185
|
+
/** The resolved config this client is bound to (for messages/JSON output). */
|
|
186
|
+
readonly cfg: ResolvedConfig;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Build a {@link DataPlaneClient} bound to `cfg.dataKey`. */
|
|
190
|
+
export function createDataPlaneClient(cfg: ResolvedConfig): DataPlaneClient {
|
|
191
|
+
const missing =
|
|
192
|
+
"no data key configured — pass --data-key, or set HOGSEND_DATA_KEY / HOGSEND_API_KEY";
|
|
193
|
+
return {
|
|
194
|
+
cfg,
|
|
195
|
+
get: <T>(path: string, query?: Query) =>
|
|
196
|
+
request<T>(cfg.baseUrl, cfg.dataKey, missing, "GET", path, {
|
|
197
|
+
query,
|
|
198
|
+
auth: true,
|
|
199
|
+
}),
|
|
140
200
|
post: <T>(path: string, body: unknown) =>
|
|
141
|
-
request<T>("POST", path, {
|
|
201
|
+
request<T>(cfg.baseUrl, cfg.dataKey, missing, "POST", path, {
|
|
202
|
+
body,
|
|
203
|
+
auth: true,
|
|
204
|
+
}),
|
|
205
|
+
put: <T>(path: string, body: unknown) =>
|
|
206
|
+
request<T>(cfg.baseUrl, cfg.dataKey, missing, "PUT", path, {
|
|
207
|
+
body,
|
|
208
|
+
auth: true,
|
|
209
|
+
}),
|
|
210
|
+
del: <T>(path: string, body?: unknown) =>
|
|
211
|
+
request<T>(cfg.baseUrl, cfg.dataKey, missing, "DELETE", path, {
|
|
212
|
+
body,
|
|
213
|
+
auth: true,
|
|
214
|
+
}),
|
|
142
215
|
};
|
|
143
216
|
}
|
|
144
217
|
|