@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 CHANGED
@@ -3,7 +3,7 @@
3
3
  // src/bin.ts
4
4
  import { createRequire as createRequire3 } from "module";
5
5
 
6
- // src/commands/contacts.ts
6
+ // src/commands/campaigns.ts
7
7
  import { parseArgs } from "util";
8
8
 
9
9
  // src/lib/http.ts
@@ -33,53 +33,81 @@ function bodyMessage(status, body) {
33
33
  }
34
34
  return `request failed with status ${status}`;
35
35
  }
36
- function createAdminClient(cfg) {
37
- async function request(method, path, opts) {
38
- if (opts.auth && !cfg.adminKey) {
39
- throw makeHttpError(
40
- "no admin key configured \u2014 pass --admin-key, or set HOGSEND_ADMIN_KEY / ADMIN_API_KEY",
41
- 0,
42
- void 0
43
- );
44
- }
45
- const headers = { Accept: "application/json" };
46
- if (opts.auth && cfg.adminKey) {
47
- headers.Authorization = `Bearer ${cfg.adminKey}`;
48
- }
49
- if (opts.body !== void 0) {
50
- headers["Content-Type"] = "application/json";
51
- }
52
- const url = buildUrl(cfg.baseUrl, path, opts.query);
53
- let res;
36
+ async function request(baseUrl, key, missingKeyMessage, method, path, opts) {
37
+ if (opts.auth && !key) {
38
+ throw makeHttpError(missingKeyMessage, 0, void 0);
39
+ }
40
+ const headers = { Accept: "application/json" };
41
+ if (opts.auth && key) {
42
+ headers.Authorization = `Bearer ${key}`;
43
+ }
44
+ if (opts.body !== void 0) {
45
+ headers["Content-Type"] = "application/json";
46
+ }
47
+ const url = buildUrl(baseUrl, path, opts.query);
48
+ let res;
49
+ try {
50
+ res = await fetch(url, {
51
+ method,
52
+ headers,
53
+ body: opts.body !== void 0 ? JSON.stringify(opts.body) : void 0
54
+ });
55
+ } catch (cause) {
56
+ const msg = cause instanceof Error ? cause.message : String(cause);
57
+ throw makeHttpError(`cannot reach ${baseUrl} (${msg})`, 0, void 0);
58
+ }
59
+ const text = await res.text();
60
+ let parsed;
61
+ if (text.length > 0) {
54
62
  try {
55
- res = await fetch(url, {
56
- method,
57
- headers,
58
- body: opts.body !== void 0 ? JSON.stringify(opts.body) : void 0
59
- });
60
- } catch (cause) {
61
- const msg = cause instanceof Error ? cause.message : String(cause);
62
- throw makeHttpError(`cannot reach ${cfg.baseUrl} (${msg})`, 0, void 0);
63
- }
64
- const text = await res.text();
65
- let parsed;
66
- if (text.length > 0) {
67
- try {
68
- parsed = JSON.parse(text);
69
- } catch {
70
- parsed = text;
71
- }
63
+ parsed = JSON.parse(text);
64
+ } catch {
65
+ parsed = text;
72
66
  }
73
- if (!res.ok) {
74
- throw makeHttpError(bodyMessage(res.status, parsed), res.status, parsed);
75
- }
76
- return parsed;
77
67
  }
68
+ if (!res.ok) {
69
+ throw makeHttpError(bodyMessage(res.status, parsed), res.status, parsed);
70
+ }
71
+ return parsed;
72
+ }
73
+ function createAdminClient(cfg) {
74
+ const missing = "no admin key configured \u2014 pass --admin-key, or set HOGSEND_ADMIN_KEY / ADMIN_API_KEY";
75
+ return {
76
+ cfg,
77
+ get: (path, query, extras) => request(cfg.baseUrl, cfg.adminKey, missing, "GET", path, {
78
+ query,
79
+ auth: extras?.auth ?? true
80
+ }),
81
+ patch: (path, body) => request(cfg.baseUrl, cfg.adminKey, missing, "PATCH", path, {
82
+ body,
83
+ auth: true
84
+ }),
85
+ post: (path, body) => request(cfg.baseUrl, cfg.adminKey, missing, "POST", path, {
86
+ body,
87
+ auth: true
88
+ })
89
+ };
90
+ }
91
+ function createDataPlaneClient(cfg) {
92
+ const missing = "no data key configured \u2014 pass --data-key, or set HOGSEND_DATA_KEY / HOGSEND_API_KEY";
78
93
  return {
79
94
  cfg,
80
- get: (path, query, extras) => request("GET", path, { query, auth: extras?.auth ?? true }),
81
- patch: (path, body) => request("PATCH", path, { body, auth: true }),
82
- post: (path, body) => request("POST", path, { body, auth: true })
95
+ get: (path, query) => request(cfg.baseUrl, cfg.dataKey, missing, "GET", path, {
96
+ query,
97
+ auth: true
98
+ }),
99
+ post: (path, body) => request(cfg.baseUrl, cfg.dataKey, missing, "POST", path, {
100
+ body,
101
+ auth: true
102
+ }),
103
+ put: (path, body) => request(cfg.baseUrl, cfg.dataKey, missing, "PUT", path, {
104
+ body,
105
+ auth: true
106
+ }),
107
+ del: (path, body) => request(cfg.baseUrl, cfg.dataKey, missing, "DELETE", path, {
108
+ body,
109
+ auth: true
110
+ })
83
111
  };
84
112
  }
85
113
 
@@ -198,15 +226,249 @@ ${title}`);
198
226
  };
199
227
  }
200
228
 
229
+ // src/commands/campaigns.ts
230
+ var usage = `hogsend campaigns <subcommand> [options]
231
+
232
+ Queue and inspect broadcasts: durably send one email template to every
233
+ subscribed member of a list (or every active member of a bucket). Wraps the
234
+ data-plane campaigns routes (POST /v1/campaigns, GET /v1/campaigns/{id}).
235
+
236
+ Subcommands:
237
+ send Queue a campaign. Sends run async in the worker.
238
+ status <id> Show a campaign's status + send counts.
239
+
240
+ send options (exactly one of --list / --bucket, plus --template, required):
241
+ --list <id> Target every subscribed member of this list.
242
+ --bucket <id> Target every active member of this bucket.
243
+ --template <key> Email template to send.
244
+ --prop <key=value> Template prop; repeatable. Value parsed as JSON, falling
245
+ back to a string.
246
+ --props <json> Template props as one JSON object (merged with --prop).
247
+ --name <text> Human label for the campaign.
248
+ --from <addr> Override the default From address.
249
+ --subject <text> Override the rendered subject.
250
+
251
+ Global options (handled by the router): --url, --admin-key, --data-key, --json,
252
+ -h/--help.
253
+
254
+ Examples:
255
+ hogsend campaigns send --list newsletter --template june-update --name "June"
256
+ hogsend campaigns send --bucket power-users --template feature-launch --json
257
+ hogsend campaigns status cmp_123 --json`;
258
+ var badge = `${color.bgMagenta(color.black(" hogsend "))} campaigns`;
259
+ function parseProps(ctx, propsJson, propPairs) {
260
+ const out = {};
261
+ let any = false;
262
+ if (propsJson !== void 0) {
263
+ let parsed;
264
+ try {
265
+ parsed = JSON.parse(propsJson);
266
+ } catch {
267
+ ctx.out.fail(`--props must be valid JSON, got: ${propsJson}`);
268
+ }
269
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
270
+ ctx.out.fail("--props must be a JSON object");
271
+ }
272
+ Object.assign(out, parsed);
273
+ any = true;
274
+ }
275
+ for (const pair of propPairs ?? []) {
276
+ const eq = pair.indexOf("=");
277
+ if (eq === -1) {
278
+ ctx.out.fail(`--prop must be key=value, got: ${pair}`);
279
+ }
280
+ const key = pair.slice(0, eq).trim();
281
+ if (key === "") {
282
+ ctx.out.fail(`--prop key cannot be empty, got: ${pair}`);
283
+ }
284
+ out[key] = coerceValue(pair.slice(eq + 1));
285
+ any = true;
286
+ }
287
+ return any ? out : void 0;
288
+ }
289
+ function coerceValue(raw) {
290
+ try {
291
+ return JSON.parse(raw);
292
+ } catch {
293
+ return raw;
294
+ }
295
+ }
296
+ function statusColor(status) {
297
+ switch (status) {
298
+ case "sent":
299
+ return color.green(status);
300
+ case "queued":
301
+ case "sending":
302
+ return color.cyan(status);
303
+ default:
304
+ return color.red(status);
305
+ }
306
+ }
307
+ async function runSend(ctx, argv) {
308
+ const { values } = parseArgs({
309
+ args: argv,
310
+ allowPositionals: true,
311
+ options: {
312
+ list: { type: "string" },
313
+ bucket: { type: "string" },
314
+ template: { type: "string" },
315
+ prop: { type: "string", multiple: true },
316
+ props: { type: "string" },
317
+ name: { type: "string" },
318
+ from: { type: "string" },
319
+ subject: { type: "string" },
320
+ help: { type: "boolean", short: "h", default: false }
321
+ }
322
+ });
323
+ if (values.help) {
324
+ ctx.out.log(usage);
325
+ return;
326
+ }
327
+ const list = values.list;
328
+ const bucket = values.bucket;
329
+ if (list && bucket || !list && !bucket) {
330
+ ctx.out.fail("campaigns send requires exactly one of --list or --bucket");
331
+ }
332
+ const template = values.template;
333
+ if (!template) {
334
+ ctx.out.fail(
335
+ "campaigns send requires --template, e.g. hogsend campaigns send --list newsletter --template welcome"
336
+ );
337
+ }
338
+ const props = parseProps(ctx, values.props, values.prop);
339
+ const body = { template };
340
+ if (list) body.list = list;
341
+ if (bucket) body.bucket = bucket;
342
+ if (props) body.props = props;
343
+ if (values.name) body.name = values.name;
344
+ if (values.from) body.from = values.from;
345
+ if (values.subject) body.subject = values.subject;
346
+ let res;
347
+ try {
348
+ res = await ctx.out.step(
349
+ `Queuing campaign ${template}`,
350
+ () => ctx.dataHttp.post("/v1/campaigns", body)
351
+ );
352
+ } catch (error) {
353
+ if (isHttpError(error)) {
354
+ ctx.out.fail(error.message);
355
+ }
356
+ throw error;
357
+ }
358
+ if (ctx.json) {
359
+ ctx.out.json(res);
360
+ return;
361
+ }
362
+ ctx.out.intro(`${badge} send`);
363
+ ctx.out.kv(
364
+ {
365
+ campaignId: res.campaignId,
366
+ template,
367
+ audience: list ? `list:${list}` : `bucket:${bucket}`,
368
+ status: statusColor(res.status)
369
+ },
370
+ "Campaign queued"
371
+ );
372
+ ctx.out.outro(
373
+ `${color.green("Queued")} \u2014 poll ${color.cyan(`hogsend campaigns status ${res.campaignId}`)} for progress.`
374
+ );
375
+ }
376
+ async function runStatus(ctx, argv) {
377
+ const { values, positionals } = parseArgs({
378
+ args: argv,
379
+ allowPositionals: true,
380
+ options: {
381
+ help: { type: "boolean", short: "h", default: false }
382
+ }
383
+ });
384
+ if (values.help) {
385
+ ctx.out.log(usage);
386
+ return;
387
+ }
388
+ const id = positionals[0];
389
+ if (!id) {
390
+ ctx.out.fail(
391
+ "campaigns status requires a campaign id, e.g. hogsend campaigns status cmp_123"
392
+ );
393
+ }
394
+ let res;
395
+ try {
396
+ res = await ctx.out.step(
397
+ `Fetching campaign ${id}`,
398
+ () => ctx.dataHttp.get(
399
+ `/v1/campaigns/${encodeURIComponent(id)}`
400
+ )
401
+ );
402
+ } catch (error) {
403
+ if (isHttpError(error)) {
404
+ ctx.out.fail(error.message);
405
+ }
406
+ throw error;
407
+ }
408
+ if (ctx.json) {
409
+ ctx.out.json(res);
410
+ return;
411
+ }
412
+ ctx.out.intro(`${badge} status`);
413
+ ctx.out.kv(
414
+ {
415
+ id: res.id,
416
+ name: res.name,
417
+ status: statusColor(res.status),
418
+ audience: `${res.audienceKind}:${res.audienceId}`,
419
+ template: res.templateKey,
420
+ recipients: res.totalRecipients,
421
+ sent: color.green(String(res.sentCount)),
422
+ skipped: color.yellow(String(res.skippedCount)),
423
+ failed: res.failedCount > 0 ? color.red(String(res.failedCount)) : String(res.failedCount),
424
+ startedAt: res.startedAt ?? "",
425
+ completedAt: res.completedAt ?? ""
426
+ },
427
+ "Campaign"
428
+ );
429
+ ctx.out.outro(
430
+ `${res.name} \u2192 ${statusColor(res.status)} ` + color.dim(
431
+ `(${res.sentCount}/${res.totalRecipients} sent, ${res.skippedCount} skipped, ${res.failedCount} failed)`
432
+ )
433
+ );
434
+ }
435
+ async function run(ctx) {
436
+ const sub = ctx.argv[0];
437
+ switch (sub) {
438
+ case "send":
439
+ return runSend(ctx, ctx.argv.slice(1));
440
+ case "status":
441
+ return runStatus(ctx, ctx.argv.slice(1));
442
+ case void 0:
443
+ ctx.out.fail(
444
+ "campaigns requires a subcommand: send | status (see hogsend campaigns --help)"
445
+ );
446
+ break;
447
+ default:
448
+ ctx.out.fail(
449
+ `unknown campaigns subcommand "${sub}" \u2014 expected send or status`
450
+ );
451
+ }
452
+ }
453
+ var campaignsCommand = {
454
+ name: "campaigns",
455
+ summary: "Queue a broadcast to a list/bucket, or check its status",
456
+ usage,
457
+ run
458
+ };
459
+
201
460
  // src/commands/contacts.ts
202
- var usage = `hogsend contacts <subcommand> [options]
461
+ import { parseArgs as parseArgs2 } from "util";
462
+ var usage2 = `hogsend contacts <subcommand> [options]
203
463
 
204
- Inspect contacts via the running app's admin API (/v1/admin/contacts).
464
+ Inspect contacts via the admin API (/v1/admin/contacts) and upsert them via the
465
+ data plane (PUT /v1/contacts).
205
466
 
206
467
  Subcommands:
207
468
  list List contacts (newest activity first).
208
469
  get <id> Get one contact (by id or externalId) + preferences.
209
470
  timeline <id> Merged event/email/journey activity for a contact.
471
+ upsert Create or update a contact (PUT /v1/contacts).
210
472
 
211
473
  list options:
212
474
  --search <q> Filter by email/externalId substring.
@@ -218,13 +480,76 @@ timeline options:
218
480
  --limit <n> Page size (1-100, default 50).
219
481
  --offset <n> Page offset (default 0).
220
482
 
221
- Global options (handled by the router): --url, --admin-key, --json, -h/--help.
483
+ upsert options (at least one of --email / --user-id required):
484
+ --email <addr> Contact email (a resolvable identity key).
485
+ --user-id <id> External (distinct) id.
486
+ --prop <key=value> Contact property; repeatable. Value parsed as JSON,
487
+ falling back to a string. Uses the data plane (ingest key).
488
+ --props <json> Contact properties as one JSON object (merged with --prop).
489
+ --list <id> Subscribe to a list; repeatable.
490
+ --unlist <id> Unsubscribe from a list; repeatable.
491
+
492
+ Global options (handled by the router): --url, --admin-key, --data-key, --json,
493
+ -h/--help.
222
494
 
223
495
  Examples:
224
496
  hogsend contacts list --search acme@ --json
225
497
  hogsend contacts get user_123
226
- hogsend contacts timeline user_123 --type email --json`;
227
- var badge = `${color.bgMagenta(color.black(" hogsend "))} contacts`;
498
+ hogsend contacts timeline user_123 --type email --json
499
+ hogsend contacts upsert --email a@b.com --user-id user_123 --prop plan=pro
500
+ hogsend contacts upsert --user-id user_123 --props '{"plan":"pro","seats":5}'`;
501
+ var badge2 = `${color.bgMagenta(color.black(" hogsend "))} contacts`;
502
+ function parseProps2(ctx, propsJson, propPairs) {
503
+ const out = {};
504
+ let any = false;
505
+ if (propsJson !== void 0) {
506
+ let parsed;
507
+ try {
508
+ parsed = JSON.parse(propsJson);
509
+ } catch {
510
+ ctx.out.fail(`--props must be valid JSON, got: ${propsJson}`);
511
+ }
512
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
513
+ ctx.out.fail("--props must be a JSON object");
514
+ }
515
+ Object.assign(out, parsed);
516
+ any = true;
517
+ }
518
+ for (const pair of propPairs ?? []) {
519
+ const eq = pair.indexOf("=");
520
+ if (eq === -1) {
521
+ ctx.out.fail(`--prop must be key=value, got: ${pair}`);
522
+ }
523
+ const key = pair.slice(0, eq).trim();
524
+ if (key === "") {
525
+ ctx.out.fail(`--prop key cannot be empty, got: ${pair}`);
526
+ }
527
+ const raw = pair.slice(eq + 1);
528
+ out[key] = coerceValue2(raw);
529
+ any = true;
530
+ }
531
+ return any ? out : void 0;
532
+ }
533
+ function coerceValue2(raw) {
534
+ try {
535
+ return JSON.parse(raw);
536
+ } catch {
537
+ return raw;
538
+ }
539
+ }
540
+ function parseLists(subscribe, unsubscribe) {
541
+ const out = {};
542
+ let any = false;
543
+ for (const id of subscribe ?? []) {
544
+ out[id] = true;
545
+ any = true;
546
+ }
547
+ for (const id of unsubscribe ?? []) {
548
+ out[id] = false;
549
+ any = true;
550
+ }
551
+ return any ? out : void 0;
552
+ }
228
553
  async function fetchOrFail(ctx, label, fn) {
229
554
  try {
230
555
  return await ctx.out.step(label, fn);
@@ -239,7 +564,7 @@ async function fetchOrFail(ctx, label, fn) {
239
564
  }
240
565
  }
241
566
  async function runList(ctx, argv) {
242
- const { values } = parseArgs({
567
+ const { values } = parseArgs2({
243
568
  args: argv,
244
569
  allowPositionals: true,
245
570
  options: {
@@ -250,7 +575,7 @@ async function runList(ctx, argv) {
250
575
  }
251
576
  });
252
577
  if (values.help) {
253
- ctx.out.log(usage);
578
+ ctx.out.log(usage2);
254
579
  return;
255
580
  }
256
581
  const query = {
@@ -258,7 +583,7 @@ async function runList(ctx, argv) {
258
583
  limit: values.limit,
259
584
  offset: values.offset
260
585
  };
261
- if (!ctx.json) ctx.out.intro(`${badge} list`);
586
+ if (!ctx.json) ctx.out.intro(`${badge2} list`);
262
587
  const res = await fetchOrFail(
263
588
  ctx,
264
589
  "Fetching contacts",
@@ -282,7 +607,7 @@ async function runList(ctx, argv) {
282
607
  );
283
608
  }
284
609
  async function runGet(ctx, argv) {
285
- const { values, positionals } = parseArgs({
610
+ const { values, positionals } = parseArgs2({
286
611
  args: argv,
287
612
  allowPositionals: true,
288
613
  options: {
@@ -290,7 +615,7 @@ async function runGet(ctx, argv) {
290
615
  }
291
616
  });
292
617
  if (values.help) {
293
- ctx.out.log(usage);
618
+ ctx.out.log(usage2);
294
619
  return;
295
620
  }
296
621
  const id = positionals[1];
@@ -299,7 +624,7 @@ async function runGet(ctx, argv) {
299
624
  "contacts get requires an id, e.g. hogsend contacts get user_123"
300
625
  );
301
626
  }
302
- if (!ctx.json) ctx.out.intro(`${badge} get`);
627
+ if (!ctx.json) ctx.out.intro(`${badge2} get`);
303
628
  const res = await fetchOrFail(
304
629
  ctx,
305
630
  "Fetching contact",
@@ -337,7 +662,7 @@ async function runGet(ctx, argv) {
337
662
  ctx.out.outro(`Contact ${color.cyan(contact.externalId)}`);
338
663
  }
339
664
  async function runTimeline(ctx, argv) {
340
- const { values, positionals } = parseArgs({
665
+ const { values, positionals } = parseArgs2({
341
666
  args: argv,
342
667
  allowPositionals: true,
343
668
  options: {
@@ -348,7 +673,7 @@ async function runTimeline(ctx, argv) {
348
673
  }
349
674
  });
350
675
  if (values.help) {
351
- ctx.out.log(usage);
676
+ ctx.out.log(usage2);
352
677
  return;
353
678
  }
354
679
  const id = positionals[1];
@@ -365,7 +690,7 @@ async function runTimeline(ctx, argv) {
365
690
  limit: values.limit,
366
691
  offset: values.offset
367
692
  };
368
- if (!ctx.json) ctx.out.intro(`${badge} timeline`);
693
+ if (!ctx.json) ctx.out.intro(`${badge2} timeline`);
369
694
  const res = await fetchOrFail(
370
695
  ctx,
371
696
  "Fetching timeline",
@@ -401,7 +726,62 @@ function summarizeTimelineEntry(entry) {
401
726
  const subject = d.subject ? String(d.subject) : String(d.templateKey ?? "");
402
727
  return `${subject} [${String(d.status ?? "")}]`;
403
728
  }
404
- async function run(ctx) {
729
+ async function runUpsert(ctx, argv) {
730
+ const { values } = parseArgs2({
731
+ args: argv,
732
+ allowPositionals: true,
733
+ options: {
734
+ email: { type: "string" },
735
+ "user-id": { type: "string" },
736
+ prop: { type: "string", multiple: true },
737
+ props: { type: "string" },
738
+ list: { type: "string", multiple: true },
739
+ unlist: { type: "string", multiple: true },
740
+ help: { type: "boolean", short: "h", default: false }
741
+ }
742
+ });
743
+ if (values.help) {
744
+ ctx.out.log(usage2);
745
+ return;
746
+ }
747
+ const email = values.email;
748
+ const userId = values["user-id"];
749
+ if (!email && !userId) {
750
+ ctx.out.fail(
751
+ "contacts upsert requires at least one of --email or --user-id"
752
+ );
753
+ }
754
+ const properties = parseProps2(ctx, values.props, values.prop);
755
+ const lists = parseLists(values.list, values.unlist);
756
+ const body = {};
757
+ if (email) body.email = email;
758
+ if (userId) body.userId = userId;
759
+ if (properties) body.properties = properties;
760
+ if (lists) body.lists = lists;
761
+ if (!ctx.json) ctx.out.intro(`${badge2} upsert`);
762
+ const res = await fetchOrFail(
763
+ ctx,
764
+ "Upserting contact",
765
+ () => ctx.dataHttp.put("/v1/contacts", body)
766
+ );
767
+ if (ctx.json) {
768
+ ctx.out.json(res);
769
+ return;
770
+ }
771
+ ctx.out.kv(
772
+ {
773
+ id: res.id,
774
+ created: res.created,
775
+ linked: res.linked,
776
+ email: email ?? color.dim("(none)"),
777
+ userId: userId ?? color.dim("(none)")
778
+ },
779
+ "Contact"
780
+ );
781
+ const verb = res.created ? "created" : "updated";
782
+ ctx.out.outro(`Contact ${color.cyan(res.id)} ${verb}.`);
783
+ }
784
+ async function run2(ctx) {
405
785
  const sub = ctx.argv[0];
406
786
  switch (sub) {
407
787
  case "list":
@@ -410,26 +790,28 @@ async function run(ctx) {
410
790
  return runGet(ctx, ctx.argv);
411
791
  case "timeline":
412
792
  return runTimeline(ctx, ctx.argv);
793
+ case "upsert":
794
+ return runUpsert(ctx, ctx.argv.slice(1));
413
795
  case void 0:
414
796
  ctx.out.fail(
415
- "contacts requires a subcommand: list, get, or timeline (see hogsend contacts --help)"
797
+ "contacts requires a subcommand: list, get, timeline, or upsert (see hogsend contacts --help)"
416
798
  );
417
799
  break;
418
800
  default:
419
801
  ctx.out.fail(
420
- `unknown contacts subcommand "${sub}" \u2014 expected list, get, or timeline`
802
+ `unknown contacts subcommand "${sub}" \u2014 expected list, get, timeline, or upsert`
421
803
  );
422
804
  }
423
805
  }
424
806
  var contactsCommand = {
425
807
  name: "contacts",
426
- summary: "List, inspect, and trace contact activity",
427
- usage,
428
- run
808
+ summary: "List, inspect, trace, and upsert contacts",
809
+ usage: usage2,
810
+ run: run2
429
811
  };
430
812
 
431
813
  // src/commands/doctor.ts
432
- import { parseArgs as parseArgs2 } from "util";
814
+ import { parseArgs as parseArgs3 } from "util";
433
815
 
434
816
  // src/lib/skills.ts
435
817
  import {
@@ -561,7 +943,7 @@ function skillsNudge(ctx) {
561
943
  "Skills out of date"
562
944
  );
563
945
  }
564
- var usage2 = `hogsend doctor [--url <baseUrl>] [--admin-key <key>] [--json]
946
+ var usage3 = `hogsend doctor [--url <baseUrl>] [--admin-key <key>] [--json]
565
947
 
566
948
  Probe a running Hogsend instance via GET /v1/health and report its health:
567
949
  component status (database, redis), two-track schema state (engine + client),
@@ -603,8 +985,8 @@ function trackLine(name, track) {
603
985
  const required = track.required ?? color.dim("none");
604
986
  return `${color.bold(name.padEnd(7))} applied ${applied} -> required ${required} ${sync}`;
605
987
  }
606
- async function run2(ctx) {
607
- const { values } = parseArgs2({
988
+ async function run3(ctx) {
989
+ const { values } = parseArgs3({
608
990
  args: ctx.argv,
609
991
  allowPositionals: true,
610
992
  options: {
@@ -614,7 +996,7 @@ async function run2(ctx) {
614
996
  strict: false
615
997
  });
616
998
  if (values.help) {
617
- ctx.out.log(usage2);
999
+ ctx.out.log(usage3);
618
1000
  return;
619
1001
  }
620
1002
  const { baseUrl } = ctx.http.cfg;
@@ -675,8 +1057,8 @@ async function run2(ctx) {
675
1057
  if (!ok) process.exit(1);
676
1058
  return;
677
1059
  }
678
- const badge3 = `${color.bgMagenta(color.black(" hogsend "))} doctor`;
679
- ctx.out.intro(badge3);
1060
+ const badge5 = `${color.bgMagenta(color.black(" hogsend "))} doctor`;
1061
+ ctx.out.intro(badge5);
680
1062
  const verdictColor = verdict === "ok" ? color.green : verdict === "degraded" ? color.red : color.yellow;
681
1063
  const lines = [
682
1064
  `${verdictColor("\u25CF")} ${color.bold(verdict)}`,
@@ -704,15 +1086,15 @@ async function run2(ctx) {
704
1086
  var doctorCommand = {
705
1087
  name: "doctor",
706
1088
  summary: "Probe a running instance's health (GET /v1/health)",
707
- usage: usage2,
708
- run: run2
1089
+ usage: usage3,
1090
+ run: run3
709
1091
  };
710
1092
 
711
1093
  // src/commands/eject.ts
712
1094
  import { existsSync as existsSync3, realpathSync } from "fs";
713
1095
  import { createRequire as createRequire2 } from "module";
714
1096
  import { dirname, join as join3, sep as sep2 } from "path";
715
- import { parseArgs as parseArgs3 } from "util";
1097
+ import { parseArgs as parseArgs4 } from "util";
716
1098
 
717
1099
  // src/eject.ts
718
1100
  import { existsSync as existsSync2 } from "fs";
@@ -826,7 +1208,7 @@ async function countFiles(dir) {
826
1208
  }
827
1209
 
828
1210
  // src/commands/eject.ts
829
- var usage3 = `hogsend eject <package> [--force] [--cwd <dir>]
1211
+ var usage4 = `hogsend eject <package> [--force] [--cwd <dir>]
830
1212
 
831
1213
  Copy a @hogsend/* package's source into vendor/<name> and rewrite the consumer
832
1214
  dependency to file:./vendor/<name>. Every other dependency keeps upgrading.
@@ -854,8 +1236,8 @@ function resolveSourceDir(pkg, consumerRoot) {
854
1236
  }
855
1237
  return null;
856
1238
  }
857
- async function run3(ctx) {
858
- const { values, positionals } = parseArgs3({
1239
+ async function run4(ctx) {
1240
+ const { values, positionals } = parseArgs4({
859
1241
  args: ctx.argv,
860
1242
  allowPositionals: true,
861
1243
  options: {
@@ -865,7 +1247,7 @@ async function run3(ctx) {
865
1247
  }
866
1248
  });
867
1249
  if (values.help) {
868
- ctx.out.log(usage3);
1250
+ ctx.out.log(usage4);
869
1251
  return;
870
1252
  }
871
1253
  const pkg = positionals[0];
@@ -909,36 +1291,242 @@ async function run3(ctx) {
909
1291
  var ejectCommand = {
910
1292
  name: "eject",
911
1293
  summary: "Vendor a @hogsend/* package into vendor/<name>",
912
- usage: usage3,
913
- run: run3
1294
+ usage: usage4,
1295
+ run: run4
914
1296
  };
915
1297
 
916
- // src/commands/events.ts
917
- import { parseArgs as parseArgs4 } from "util";
918
- var usage4 = `hogsend events <userId> [options]
1298
+ // src/commands/emails.ts
1299
+ import { parseArgs as parseArgs5 } from "util";
1300
+ var usage5 = `hogsend emails <subcommand> [options]
919
1301
 
920
- Stream the event history for a single user, newest first. Wraps
921
- GET /v1/admin/events?userId=<userId>.
1302
+ Send a transactional email through the data plane (POST /v1/emails). The send
1303
+ runs through the full preferences + tracking pipeline (link-click + open).
922
1304
 
923
- Arguments:
924
- <userId> The user (distinct) id to fetch events for. Required.
1305
+ Subcommands:
1306
+ send <template> Send the named template to a recipient.
1307
+
1308
+ send options (at least one of --to / --user-id required):
1309
+ --to <addr> Recipient email address.
1310
+ --user-id <id> External (distinct) id; the recipient email is resolved
1311
+ from the contact (404 if it has no resolvable email).
1312
+ --prop <key=value> Template prop; repeatable. Value parsed as JSON, falling
1313
+ back to a string.
1314
+ --props <json> Template props as one JSON object (merged with --prop).
1315
+ --from <addr> Override the default From address.
1316
+ --subject <text> Override the rendered subject.
1317
+ --reply-to <addr> Set the Reply-To address.
1318
+ --category <key> Preference category / list id to gate the send on.
1319
+ --skip-preference-check Bypass unsubscribe/suppression (requires full-admin).
1320
+ --idempotency-key <k> Dedup key.
1321
+
1322
+ Global options (handled by the router): --url, --admin-key, --data-key, --json,
1323
+ -h/--help.
925
1324
 
926
- Options:
927
- --event <name> Filter to a single event name.
928
- --from <iso> Only events at/after this ISO-8601 timestamp.
929
- --to <iso> Only events at/before this ISO-8601 timestamp.
930
- --limit <n> Max events to return (1-100, default 50).
931
- --offset <n> Pagination offset (default 0).
932
- --json Emit machine-readable JSON only.
933
- -h, --help Show this help.
1325
+ Examples:
1326
+ hogsend emails send welcome --to a@b.com --prop name=Ada
1327
+ hogsend emails send welcome --user-id user_123 --props '{"name":"Ada"}' --json`;
1328
+ var badge3 = `${color.bgMagenta(color.black(" hogsend "))} emails`;
1329
+ function parseProps3(ctx, propsJson, propPairs) {
1330
+ const out = {};
1331
+ let any = false;
1332
+ if (propsJson !== void 0) {
1333
+ let parsed;
1334
+ try {
1335
+ parsed = JSON.parse(propsJson);
1336
+ } catch {
1337
+ ctx.out.fail(`--props must be valid JSON, got: ${propsJson}`);
1338
+ }
1339
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
1340
+ ctx.out.fail("--props must be a JSON object");
1341
+ }
1342
+ Object.assign(out, parsed);
1343
+ any = true;
1344
+ }
1345
+ for (const pair of propPairs ?? []) {
1346
+ const eq = pair.indexOf("=");
1347
+ if (eq === -1) {
1348
+ ctx.out.fail(`--prop must be key=value, got: ${pair}`);
1349
+ }
1350
+ const key = pair.slice(0, eq).trim();
1351
+ if (key === "") {
1352
+ ctx.out.fail(`--prop key cannot be empty, got: ${pair}`);
1353
+ }
1354
+ out[key] = coerceValue3(pair.slice(eq + 1));
1355
+ any = true;
1356
+ }
1357
+ return any ? out : void 0;
1358
+ }
1359
+ function coerceValue3(raw) {
1360
+ try {
1361
+ return JSON.parse(raw);
1362
+ } catch {
1363
+ return raw;
1364
+ }
1365
+ }
1366
+ function statusColor2(status) {
1367
+ switch (status) {
1368
+ case "queued":
1369
+ case "sent":
1370
+ return color.green(status);
1371
+ case "skipped":
1372
+ return color.dim(status);
1373
+ default:
1374
+ return color.yellow(status);
1375
+ }
1376
+ }
1377
+ async function runSend2(ctx, argv) {
1378
+ const { values, positionals } = parseArgs5({
1379
+ args: argv,
1380
+ allowPositionals: true,
1381
+ options: {
1382
+ to: { type: "string" },
1383
+ "user-id": { type: "string" },
1384
+ prop: { type: "string", multiple: true },
1385
+ props: { type: "string" },
1386
+ from: { type: "string" },
1387
+ subject: { type: "string" },
1388
+ "reply-to": { type: "string" },
1389
+ category: { type: "string" },
1390
+ "skip-preference-check": { type: "boolean", default: false },
1391
+ "idempotency-key": { type: "string" },
1392
+ help: { type: "boolean", short: "h", default: false }
1393
+ }
1394
+ });
1395
+ if (values.help) {
1396
+ ctx.out.log(usage5);
1397
+ return;
1398
+ }
1399
+ const template = positionals[0];
1400
+ if (!template) {
1401
+ ctx.out.fail(
1402
+ "emails send requires a template, e.g. hogsend emails send welcome --to a@b.com"
1403
+ );
1404
+ }
1405
+ const to = values.to;
1406
+ const userId = values["user-id"];
1407
+ if (!to && !userId) {
1408
+ ctx.out.fail("emails send requires at least one of --to or --user-id");
1409
+ }
1410
+ const props = parseProps3(ctx, values.props, values.prop);
1411
+ const body = { template };
1412
+ if (to) body.to = to;
1413
+ if (userId) body.userId = userId;
1414
+ if (props) body.props = props;
1415
+ if (values.from) body.from = values.from;
1416
+ if (values.subject) body.subject = values.subject;
1417
+ if (values["reply-to"]) body.replyTo = values["reply-to"];
1418
+ if (values.category) body.category = values.category;
1419
+ if (values["skip-preference-check"]) body.skipPreferenceCheck = true;
1420
+ if (values["idempotency-key"]) {
1421
+ body.idempotencyKey = values["idempotency-key"];
1422
+ }
1423
+ let res;
1424
+ try {
1425
+ res = await ctx.out.step(
1426
+ `Sending ${template}`,
1427
+ () => ctx.dataHttp.post("/v1/emails", body)
1428
+ );
1429
+ } catch (error) {
1430
+ if (isHttpError(error)) {
1431
+ ctx.out.fail(error.message);
1432
+ }
1433
+ throw error;
1434
+ }
1435
+ if (ctx.json) {
1436
+ ctx.out.json(res);
1437
+ return;
1438
+ }
1439
+ ctx.out.intro(`${badge3} send`);
1440
+ ctx.out.kv(
1441
+ {
1442
+ emailSendId: res.emailSendId,
1443
+ template,
1444
+ recipient: to ?? userId ?? "",
1445
+ status: statusColor2(res.status),
1446
+ reason: res.reason ?? ""
1447
+ },
1448
+ "Email send"
1449
+ );
1450
+ ctx.out.outro(`${template} \u2192 ${statusColor2(res.status)}.`);
1451
+ }
1452
+ async function run5(ctx) {
1453
+ const sub = ctx.argv[0];
1454
+ switch (sub) {
1455
+ case "send":
1456
+ return runSend2(ctx, ctx.argv.slice(1));
1457
+ case void 0:
1458
+ ctx.out.fail(
1459
+ "emails requires a subcommand: send (see hogsend emails --help)"
1460
+ );
1461
+ break;
1462
+ default:
1463
+ ctx.out.fail(`unknown emails subcommand "${sub}" \u2014 expected send`);
1464
+ }
1465
+ }
1466
+ var emailsCommand = {
1467
+ name: "emails",
1468
+ summary: "Send a transactional email through the data plane",
1469
+ usage: usage5,
1470
+ run: run5
1471
+ };
1472
+
1473
+ // src/commands/events.ts
1474
+ import { parseArgs as parseArgs6 } from "util";
1475
+ var usage6 = `hogsend events <userId> [options]
1476
+ hogsend events send <name> [options]
1477
+
1478
+ Read a single user's event history (admin API), or send an event into the data
1479
+ plane to drive journeys/buckets.
1480
+
1481
+ Read mode \u2014 hogsend events <userId>:
1482
+ Stream the event history for a single user, newest first. Wraps
1483
+ GET /v1/admin/events?userId=<userId>.
1484
+
1485
+ Arguments:
1486
+ <userId> The user (distinct) id to fetch events for. Required.
1487
+
1488
+ Options:
1489
+ --event <name> Filter to a single event name.
1490
+ --from <iso> Only events at/after this ISO-8601 timestamp.
1491
+ --to <iso> Only events at/before this ISO-8601 timestamp.
1492
+ --limit <n> Max events to return (1-100, default 50).
1493
+ --offset <n> Pagination offset (default 0).
1494
+
1495
+ Send mode \u2014 hogsend events send <name>:
1496
+ Push an event into POST /v1/events (data plane, ingest key). At least one of
1497
+ --email / --user-id is required.
1498
+
1499
+ Options:
1500
+ --email <addr> Recipient/identity email.
1501
+ --user-id <id> External (distinct) id.
1502
+ --prop <key=value> Event property; repeatable. Value parsed as JSON,
1503
+ falling back to a string.
1504
+ --props <json> Event properties as one JSON object.
1505
+ --contact-prop <k=v> Contact property to merge onto the contact; repeatable.
1506
+ --contact-props <json> Contact properties as one JSON object.
1507
+ --list <id> Subscribe to a list; repeatable.
1508
+ --unlist <id> Unsubscribe from a list; repeatable.
1509
+ --idempotency-key <k> Dedup key (sent as the Idempotency-Key header).
1510
+ --timestamp <iso> Override the event timestamp.
1511
+
1512
+ Global options (handled by the router): --url, --admin-key, --data-key, --json,
1513
+ -h/--help.
934
1514
 
935
1515
  Examples:
936
1516
  hogsend events user_123
937
1517
  hogsend events user_123 --event signup --limit 10
938
- hogsend events user_123 --from 2026-01-01T00:00:00Z --json`;
939
- async function run4(ctx) {
940
- const { values, positionals } = parseArgs4({
941
- args: ctx.argv,
1518
+ hogsend events user_123 --from 2026-01-01T00:00:00Z --json
1519
+ hogsend events send signup --user-id user_123 --prop plan=pro
1520
+ hogsend events send purchase --email a@b.com --props '{"amount":49}' --json`;
1521
+ async function run6(ctx) {
1522
+ if (ctx.argv[0] === "send") {
1523
+ return runSend3(ctx, ctx.argv.slice(1));
1524
+ }
1525
+ return runRead(ctx, ctx.argv);
1526
+ }
1527
+ async function runRead(ctx, argv) {
1528
+ const { values, positionals } = parseArgs6({
1529
+ args: argv,
942
1530
  allowPositionals: true,
943
1531
  options: {
944
1532
  event: { type: "string" },
@@ -950,7 +1538,7 @@ async function run4(ctx) {
950
1538
  }
951
1539
  });
952
1540
  if (values.help) {
953
- ctx.out.log(usage4);
1541
+ ctx.out.log(usage6);
954
1542
  return;
955
1543
  }
956
1544
  const userId = positionals[0];
@@ -1005,6 +1593,145 @@ async function run4(ctx) {
1005
1593
  `${color.green(String(shown))} event${shown === 1 ? "" : "s"} ` + color.dim(`(${data.offset + 1}-${through} of ${data.total})`)
1006
1594
  );
1007
1595
  }
1596
+ async function runSend3(ctx, argv) {
1597
+ const { values, positionals } = parseArgs6({
1598
+ args: argv,
1599
+ allowPositionals: true,
1600
+ options: {
1601
+ email: { type: "string" },
1602
+ "user-id": { type: "string" },
1603
+ prop: { type: "string", multiple: true },
1604
+ props: { type: "string" },
1605
+ "contact-prop": { type: "string", multiple: true },
1606
+ "contact-props": { type: "string" },
1607
+ list: { type: "string", multiple: true },
1608
+ unlist: { type: "string", multiple: true },
1609
+ "idempotency-key": { type: "string" },
1610
+ timestamp: { type: "string" },
1611
+ help: { type: "boolean", short: "h", default: false }
1612
+ }
1613
+ });
1614
+ if (values.help) {
1615
+ ctx.out.log(usage6);
1616
+ return;
1617
+ }
1618
+ const name = positionals[0];
1619
+ if (!name) {
1620
+ ctx.out.fail(
1621
+ "events send requires an event name, e.g. hogsend events send signup --user-id user_123"
1622
+ );
1623
+ }
1624
+ const email = values.email;
1625
+ const userId = values["user-id"];
1626
+ if (!email && !userId) {
1627
+ ctx.out.fail("events send requires at least one of --email or --user-id");
1628
+ }
1629
+ const eventProperties = parseProps4(ctx, values.props, values.prop, "prop");
1630
+ const contactProperties = parseProps4(
1631
+ ctx,
1632
+ values["contact-props"],
1633
+ values["contact-prop"],
1634
+ "contact-prop"
1635
+ );
1636
+ const lists = parseLists2(values.list, values.unlist);
1637
+ const body = { name };
1638
+ if (email) body.email = email;
1639
+ if (userId) body.userId = userId;
1640
+ if (eventProperties) body.eventProperties = eventProperties;
1641
+ if (contactProperties) body.contactProperties = contactProperties;
1642
+ if (lists) body.lists = lists;
1643
+ if (values["idempotency-key"]) {
1644
+ body.idempotencyKey = values["idempotency-key"];
1645
+ }
1646
+ if (values.timestamp) body.timestamp = values.timestamp;
1647
+ let res;
1648
+ try {
1649
+ res = await ctx.out.step(
1650
+ `Sending event ${name}`,
1651
+ () => ctx.dataHttp.post("/v1/events", body)
1652
+ );
1653
+ } catch (error) {
1654
+ if (isHttpError(error)) {
1655
+ ctx.out.fail(error.message);
1656
+ }
1657
+ throw error;
1658
+ }
1659
+ if (ctx.json) {
1660
+ ctx.out.json(res);
1661
+ return;
1662
+ }
1663
+ ctx.out.intro(`${color.bgMagenta(color.black(" hogsend "))} events send`);
1664
+ const exited = res.exits.filter((e) => e.exited);
1665
+ ctx.out.kv(
1666
+ {
1667
+ event: name,
1668
+ stored: res.stored,
1669
+ identity: email ?? userId ?? "",
1670
+ exits: res.exits.length,
1671
+ "journeys exited": exited.length
1672
+ },
1673
+ "Event sent"
1674
+ );
1675
+ if (exited.length > 0) {
1676
+ ctx.out.table(
1677
+ exited.map((e) => ({ journeyId: e.journeyId, stateId: e.stateId })),
1678
+ ["journeyId", "stateId"]
1679
+ );
1680
+ }
1681
+ ctx.out.outro(
1682
+ res.stored ? `${color.green("Stored")} ${name}.` : color.dim(`${name} was deduped (not stored).`)
1683
+ );
1684
+ }
1685
+ function parseProps4(ctx, json, pairs, flagName) {
1686
+ const out = {};
1687
+ let any = false;
1688
+ if (json !== void 0) {
1689
+ let parsed;
1690
+ try {
1691
+ parsed = JSON.parse(json);
1692
+ } catch {
1693
+ ctx.out.fail(`--${flagName}s must be valid JSON, got: ${json}`);
1694
+ }
1695
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
1696
+ ctx.out.fail(`--${flagName}s must be a JSON object`);
1697
+ }
1698
+ Object.assign(out, parsed);
1699
+ any = true;
1700
+ }
1701
+ for (const pair of pairs ?? []) {
1702
+ const eq = pair.indexOf("=");
1703
+ if (eq === -1) {
1704
+ ctx.out.fail(`--${flagName} must be key=value, got: ${pair}`);
1705
+ }
1706
+ const key = pair.slice(0, eq).trim();
1707
+ if (key === "") {
1708
+ ctx.out.fail(`--${flagName} key cannot be empty, got: ${pair}`);
1709
+ }
1710
+ out[key] = coerceValue4(pair.slice(eq + 1));
1711
+ any = true;
1712
+ }
1713
+ return any ? out : void 0;
1714
+ }
1715
+ function coerceValue4(raw) {
1716
+ try {
1717
+ return JSON.parse(raw);
1718
+ } catch {
1719
+ return raw;
1720
+ }
1721
+ }
1722
+ function parseLists2(subscribe, unsubscribe) {
1723
+ const out = {};
1724
+ let any = false;
1725
+ for (const id of subscribe ?? []) {
1726
+ out[id] = true;
1727
+ any = true;
1728
+ }
1729
+ for (const id of unsubscribe ?? []) {
1730
+ out[id] = false;
1731
+ any = true;
1732
+ }
1733
+ return any ? out : void 0;
1734
+ }
1008
1735
  function parseNumber(raw, name, ctx) {
1009
1736
  if (raw === void 0) return void 0;
1010
1737
  const n = Number(raw);
@@ -1022,14 +1749,14 @@ function summarizeProps(props) {
1022
1749
  }
1023
1750
  var eventsCommand = {
1024
1751
  name: "events",
1025
- summary: "Stream a single user's event history",
1026
- usage: usage4,
1027
- run: run4
1752
+ summary: "Stream a user's event history, or send an event",
1753
+ usage: usage6,
1754
+ run: run6
1028
1755
  };
1029
1756
 
1030
1757
  // src/commands/journeys.ts
1031
- import { parseArgs as parseArgs5 } from "util";
1032
- var usage5 = `hogsend journeys <subcommand> [options]
1758
+ import { parseArgs as parseArgs7 } from "util";
1759
+ var usage7 = `hogsend journeys <subcommand> [options]
1033
1760
 
1034
1761
  Inspect and toggle journeys via the admin API (/v1/admin/journeys).
1035
1762
 
@@ -1051,14 +1778,14 @@ Examples:
1051
1778
  hogsend journeys list --enabled true
1052
1779
  hogsend journeys get activation-welcome --json
1053
1780
  hogsend journeys disable churn-prevention`;
1054
- function badge2() {
1781
+ function badge4() {
1055
1782
  return `${color.bgMagenta(color.black(" hogsend "))} journeys`;
1056
1783
  }
1057
- function statusColor(enabled) {
1784
+ function statusColor3(enabled) {
1058
1785
  return enabled ? color.green("enabled") : color.yellow("disabled");
1059
1786
  }
1060
1787
  async function runList2(ctx) {
1061
- const { values } = parseArgs5({
1788
+ const { values } = parseArgs7({
1062
1789
  args: ctx.argv,
1063
1790
  allowPositionals: true,
1064
1791
  options: {
@@ -1069,7 +1796,7 @@ async function runList2(ctx) {
1069
1796
  }
1070
1797
  });
1071
1798
  if (values.help) {
1072
- ctx.out.log(usage5);
1799
+ ctx.out.log(usage7);
1073
1800
  return;
1074
1801
  }
1075
1802
  if (values.enabled !== void 0 && !["true", "false"].includes(values.enabled)) {
@@ -1080,7 +1807,7 @@ async function runList2(ctx) {
1080
1807
  limit: values.limit,
1081
1808
  offset: values.offset
1082
1809
  };
1083
- if (!ctx.json) ctx.out.intro(badge2());
1810
+ if (!ctx.json) ctx.out.intro(badge4());
1084
1811
  const data = await ctx.out.step(
1085
1812
  "Fetching journeys",
1086
1813
  () => ctx.http.get("/v1/admin/journeys", query)
@@ -1096,7 +1823,7 @@ async function runList2(ctx) {
1096
1823
  data.journeys.map((j) => ({
1097
1824
  id: j.id,
1098
1825
  name: j.name,
1099
- status: statusColor(j.enabled),
1826
+ status: statusColor3(j.enabled),
1100
1827
  trigger: j.trigger.event,
1101
1828
  active: j.counts.active,
1102
1829
  waiting: j.counts.waiting,
@@ -1125,7 +1852,7 @@ async function runGet2(ctx, id) {
1125
1852
  "journeys get requires a journey id, e.g. hogsend journeys get activation-welcome"
1126
1853
  );
1127
1854
  }
1128
- if (!ctx.json) ctx.out.intro(badge2());
1855
+ if (!ctx.json) ctx.out.intro(badge4());
1129
1856
  const data = await ctx.out.step(
1130
1857
  `Fetching journey ${id}`,
1131
1858
  () => ctx.http.get(
@@ -1142,7 +1869,7 @@ async function runGet2(ctx, id) {
1142
1869
  id: j.id,
1143
1870
  name: j.name,
1144
1871
  description: j.description ?? "",
1145
- status: statusColor(j.enabled),
1872
+ status: statusColor3(j.enabled),
1146
1873
  trigger: j.trigger.event,
1147
1874
  entryLimit: j.entryLimit,
1148
1875
  exitOn: j.exitOn?.map((e) => e.event).join(", ") ?? "(none)"
@@ -1182,7 +1909,7 @@ async function runToggle(ctx, id, enabled) {
1182
1909
  `journeys ${verb} requires a journey id, e.g. hogsend journeys ${verb} activation-welcome`
1183
1910
  );
1184
1911
  }
1185
- if (!ctx.json) ctx.out.intro(badge2());
1912
+ if (!ctx.json) ctx.out.intro(badge4());
1186
1913
  const data = await ctx.out.step(
1187
1914
  `${enabled ? "Enabling" : "Disabling"} ${id}`,
1188
1915
  () => ctx.http.patch(
@@ -1198,14 +1925,14 @@ async function runToggle(ctx, id, enabled) {
1198
1925
  ctx.out.note(
1199
1926
  [
1200
1927
  `${color.bold(j.name)} (${j.id})`,
1201
- `status: ${statusColor(j.enabled)}`,
1928
+ `status: ${statusColor3(j.enabled)}`,
1202
1929
  `updated: ${j.updatedAt}`
1203
1930
  ].join("\n"),
1204
1931
  `Journey ${enabled ? "enabled" : "disabled"}`
1205
1932
  );
1206
- ctx.out.outro(`${j.id} is now ${statusColor(j.enabled)}.`);
1933
+ ctx.out.outro(`${j.id} is now ${statusColor3(j.enabled)}.`);
1207
1934
  }
1208
- async function run5(ctx) {
1935
+ async function run7(ctx) {
1209
1936
  const sub = ctx.argv[0];
1210
1937
  const rest = ctx.argv.slice(1);
1211
1938
  const subCtx = { ...ctx, argv: rest };
@@ -1217,7 +1944,7 @@ async function run5(ctx) {
1217
1944
  case "get": {
1218
1945
  const id = rest.find((a) => !a.startsWith("-"));
1219
1946
  if (rest.includes("--help") || rest.includes("-h")) {
1220
- ctx.out.log(usage5);
1947
+ ctx.out.log(usage7);
1221
1948
  return;
1222
1949
  }
1223
1950
  await runGet2(subCtx, id);
@@ -1225,7 +1952,7 @@ async function run5(ctx) {
1225
1952
  }
1226
1953
  case "enable": {
1227
1954
  if (rest.includes("--help") || rest.includes("-h")) {
1228
- ctx.out.log(usage5);
1955
+ ctx.out.log(usage7);
1229
1956
  return;
1230
1957
  }
1231
1958
  await runToggle(
@@ -1237,7 +1964,7 @@ async function run5(ctx) {
1237
1964
  }
1238
1965
  case "disable": {
1239
1966
  if (rest.includes("--help") || rest.includes("-h")) {
1240
- ctx.out.log(usage5);
1967
+ ctx.out.log(usage7);
1241
1968
  return;
1242
1969
  }
1243
1970
  await runToggle(
@@ -1271,14 +1998,14 @@ async function run5(ctx) {
1271
1998
  var journeysCommand = {
1272
1999
  name: "journeys",
1273
2000
  summary: "List, inspect, enable, and disable journeys",
1274
- usage: usage5,
1275
- run: run5
2001
+ usage: usage7,
2002
+ run: run7
1276
2003
  };
1277
2004
 
1278
2005
  // src/commands/patch.ts
1279
2006
  import { spawnSync } from "child_process";
1280
- import { parseArgs as parseArgs6 } from "util";
1281
- var usage6 = `hogsend patch <package> [--cwd <dir>]
2007
+ import { parseArgs as parseArgs8 } from "util";
2008
+ var usage8 = `hogsend patch <package> [--cwd <dir>]
1282
2009
 
1283
2010
  Thin wrapper over pnpm's native patch flow. Runs \`pnpm patch <package>\`, which
1284
2011
  extracts the package into a temp dir and prints the path to edit. After editing,
@@ -1289,8 +2016,8 @@ This does NOT replace scripts/patch-check.sh (the patch re-apply contract).
1289
2016
  Options:
1290
2017
  --cwd <dir> Project root to run pnpm in (defaults to current directory).
1291
2018
  -h, --help Show this help.`;
1292
- async function run6(ctx) {
1293
- const { values, positionals } = parseArgs6({
2019
+ async function run8(ctx) {
2020
+ const { values, positionals } = parseArgs8({
1294
2021
  args: ctx.argv,
1295
2022
  allowPositionals: true,
1296
2023
  options: {
@@ -1299,7 +2026,7 @@ async function run6(ctx) {
1299
2026
  }
1300
2027
  });
1301
2028
  if (values.help) {
1302
- ctx.out.log(usage6);
2029
+ ctx.out.log(usage8);
1303
2030
  return;
1304
2031
  }
1305
2032
  const pkg = positionals[0];
@@ -1339,8 +2066,8 @@ async function run6(ctx) {
1339
2066
  var patchCommand = {
1340
2067
  name: "patch",
1341
2068
  summary: "Patch a package via pnpm's native patch flow",
1342
- usage: usage6,
1343
- run: run6
2069
+ usage: usage8,
2070
+ run: run8
1344
2071
  };
1345
2072
 
1346
2073
  // src/commands/setup.ts
@@ -1348,7 +2075,7 @@ import { spawnSync as spawnSync2 } from "child_process";
1348
2075
  import { randomBytes } from "crypto";
1349
2076
  import { copyFileSync, existsSync as existsSync4, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
1350
2077
  import { join as join4 } from "path";
1351
- import { parseArgs as parseArgs7 } from "util";
2078
+ import { parseArgs as parseArgs9 } from "util";
1352
2079
  import { confirm } from "@clack/prompts";
1353
2080
 
1354
2081
  // src/lib/prompt.ts
@@ -1362,7 +2089,7 @@ function bail(value) {
1362
2089
  }
1363
2090
 
1364
2091
  // src/commands/setup.ts
1365
- var usage7 = `hogsend setup [--cwd <dir>] [--yes] [--json]
2092
+ var usage9 = `hogsend setup [--cwd <dir>] [--yes] [--json]
1366
2093
 
1367
2094
  Interactive local onboarding for a scaffolded Hogsend app. Mirrors the
1368
2095
  create-hogsend "next steps":
@@ -1473,8 +2200,8 @@ function runCmd(cmd, args, cwd, json) {
1473
2200
  });
1474
2201
  return { status: result.status, ok: result.status === 0 };
1475
2202
  }
1476
- async function run7(ctx) {
1477
- const { values } = parseArgs7({
2203
+ async function run9(ctx) {
2204
+ const { values } = parseArgs9({
1478
2205
  args: ctx.argv,
1479
2206
  allowPositionals: true,
1480
2207
  options: {
@@ -1484,7 +2211,7 @@ async function run7(ctx) {
1484
2211
  }
1485
2212
  });
1486
2213
  if (values.help) {
1487
- ctx.out.log(usage7);
2214
+ ctx.out.log(usage9);
1488
2215
  return;
1489
2216
  }
1490
2217
  const cwd = values.cwd ?? process.cwd();
@@ -1595,16 +2322,16 @@ async function run7(ctx) {
1595
2322
  var setupCommand = {
1596
2323
  name: "setup",
1597
2324
  summary: "Local onboarding: docker compose up, gen secret, db:migrate",
1598
- usage: usage7,
1599
- run: run7
2325
+ usage: usage9,
2326
+ run: run9
1600
2327
  };
1601
2328
 
1602
2329
  // src/commands/skills.ts
1603
2330
  import { existsSync as existsSync5 } from "fs";
1604
2331
  import { join as join5 } from "path";
1605
- import { parseArgs as parseArgs8 } from "util";
2332
+ import { parseArgs as parseArgs10 } from "util";
1606
2333
  import { multiselect } from "@clack/prompts";
1607
- var usage8 = `hogsend skills <subcommand> [options]
2334
+ var usage10 = `hogsend skills <subcommand> [options]
1608
2335
 
1609
2336
  Manage the Claude Code skills bundled with @hogsend/cli. Bundled skills teach
1610
2337
  agents how to drive the hogsend CLI; \`add\` copies them into your project's
@@ -1665,7 +2392,7 @@ function runList3(ctx) {
1665
2392
  );
1666
2393
  }
1667
2394
  async function runAdd(ctx, argv) {
1668
- const { values, positionals } = parseArgs8({
2395
+ const { values, positionals } = parseArgs10({
1669
2396
  args: argv,
1670
2397
  allowPositionals: true,
1671
2398
  options: {
@@ -1675,7 +2402,7 @@ async function runAdd(ctx, argv) {
1675
2402
  }
1676
2403
  });
1677
2404
  if (values.help) {
1678
- ctx.out.log(usage8);
2405
+ ctx.out.log(usage10);
1679
2406
  return;
1680
2407
  }
1681
2408
  const cwd = process.cwd();
@@ -1743,7 +2470,7 @@ async function runAdd(ctx, argv) {
1743
2470
  `Installed ${installedCount} skill${installedCount === 1 ? "" : "s"}` + (skippedCount > 0 ? `, skipped ${skippedCount}.` : ".")
1744
2471
  );
1745
2472
  }
1746
- async function run8(ctx) {
2473
+ async function run10(ctx) {
1747
2474
  const sub = ctx.argv[0];
1748
2475
  switch (sub) {
1749
2476
  case "list":
@@ -1755,7 +2482,7 @@ async function run8(ctx) {
1755
2482
  case void 0:
1756
2483
  case "-h":
1757
2484
  case "--help":
1758
- ctx.out.log(usage8);
2485
+ ctx.out.log(usage10);
1759
2486
  return;
1760
2487
  default:
1761
2488
  ctx.out.fail(
@@ -1766,13 +2493,13 @@ async function run8(ctx) {
1766
2493
  var skillsCommand = {
1767
2494
  name: "skills",
1768
2495
  summary: "List + install bundled Claude Code skills into .claude/skills",
1769
- usage: usage8,
1770
- run: run8
2496
+ usage: usage10,
2497
+ run: run10
1771
2498
  };
1772
2499
 
1773
2500
  // src/commands/stats.ts
1774
- import { parseArgs as parseArgs9 } from "util";
1775
- var usage9 = `hogsend stats [--json]
2501
+ import { parseArgs as parseArgs11 } from "util";
2502
+ var usage11 = `hogsend stats [--json]
1776
2503
 
1777
2504
  Show system-wide overview metrics from a running Hogsend instance.
1778
2505
  Wraps GET /v1/admin/metrics/overview.
@@ -1794,8 +2521,8 @@ Options:
1794
2521
  function pct(rate) {
1795
2522
  return `${(rate * 100).toFixed(2)}%`;
1796
2523
  }
1797
- async function run9(ctx) {
1798
- const { values } = parseArgs9({
2524
+ async function run11(ctx) {
2525
+ const { values } = parseArgs11({
1799
2526
  args: ctx.argv,
1800
2527
  allowPositionals: true,
1801
2528
  options: {
@@ -1803,7 +2530,7 @@ async function run9(ctx) {
1803
2530
  }
1804
2531
  });
1805
2532
  if (values.help) {
1806
- ctx.out.log(usage9);
2533
+ ctx.out.log(usage11);
1807
2534
  return;
1808
2535
  }
1809
2536
  const metrics = await ctx.out.step(
@@ -1832,8 +2559,8 @@ async function run9(ctx) {
1832
2559
  var statsCommand = {
1833
2560
  name: "stats",
1834
2561
  summary: "Show system-wide overview metrics",
1835
- usage: usage9,
1836
- run: run9
2562
+ usage: usage11,
2563
+ run: run11
1837
2564
  };
1838
2565
 
1839
2566
  // src/commands/studio.ts
@@ -1842,8 +2569,8 @@ import { createReadStream, existsSync as existsSync6, readFileSync as readFileSy
1842
2569
  import { createServer } from "http";
1843
2570
  import { extname, join as join6, normalize, resolve, sep as sep3 } from "path";
1844
2571
  import { fileURLToPath as fileURLToPath2 } from "url";
1845
- import { parseArgs as parseArgs10 } from "util";
1846
- var usage10 = `hogsend studio [options]
2572
+ import { parseArgs as parseArgs12 } from "util";
2573
+ var usage12 = `hogsend studio [options]
1847
2574
 
1848
2575
  Serve the bundled Hogsend Studio (the admin SPA) locally and open it in a
1849
2576
  browser. The Studio is a static single-page app; this command starts a tiny
@@ -1929,8 +2656,8 @@ function openBrowser(url) {
1929
2656
  } catch {
1930
2657
  }
1931
2658
  }
1932
- async function run10(ctx) {
1933
- const { values, positionals } = parseArgs10({
2659
+ async function run12(ctx) {
2660
+ const { values, positionals } = parseArgs12({
1934
2661
  args: ctx.argv,
1935
2662
  allowPositionals: true,
1936
2663
  strict: false,
@@ -1943,7 +2670,7 @@ async function run10(ctx) {
1943
2670
  }
1944
2671
  });
1945
2672
  if (values.help) {
1946
- ctx.out.log(usage10);
2673
+ ctx.out.log(usage12);
1947
2674
  return;
1948
2675
  }
1949
2676
  const port = Number(values.port ?? "3333");
@@ -2027,17 +2754,17 @@ async function run10(ctx) {
2027
2754
  var studioCommand = {
2028
2755
  name: "studio",
2029
2756
  summary: "Serve the bundled Hogsend Studio admin SPA locally",
2030
- usage: usage10,
2031
- run: run10
2757
+ usage: usage12,
2758
+ run: run12
2032
2759
  };
2033
2760
 
2034
2761
  // src/commands/upgrade.ts
2035
2762
  import { spawnSync as spawnSync3 } from "child_process";
2036
2763
  import { existsSync as existsSync7, readFileSync as readFileSync4 } from "fs";
2037
2764
  import { join as join7 } from "path";
2038
- import { parseArgs as parseArgs11 } from "util";
2765
+ import { parseArgs as parseArgs13 } from "util";
2039
2766
  import { confirm as confirm2 } from "@clack/prompts";
2040
- var usage11 = `hogsend upgrade [--cwd <dir>] [--pm <pnpm|npm|yarn|bun>] [options]
2767
+ var usage13 = `hogsend upgrade [--cwd <dir>] [--pm <pnpm|npm|yarn|bun>] [options]
2041
2768
 
2042
2769
  Upgrade a scaffolded Hogsend app in one step:
2043
2770
  1. bump every @hogsend/* dependency to latest (or --to <version>), then
@@ -2073,8 +2800,8 @@ function hogsendDeps(cwd) {
2073
2800
  function addArgs(pm, specs) {
2074
2801
  return [pm === "npm" ? "install" : "add", ...specs];
2075
2802
  }
2076
- async function run11(ctx) {
2077
- const { values } = parseArgs11({
2803
+ async function run13(ctx) {
2804
+ const { values } = parseArgs13({
2078
2805
  args: ctx.argv,
2079
2806
  allowPositionals: true,
2080
2807
  options: {
@@ -2088,7 +2815,7 @@ async function run11(ctx) {
2088
2815
  }
2089
2816
  });
2090
2817
  if (values.help) {
2091
- ctx.out.log(usage11);
2818
+ ctx.out.log(usage13);
2092
2819
  return;
2093
2820
  }
2094
2821
  if (values["deps-only"] && values["skills-only"]) {
@@ -2213,8 +2940,8 @@ async function run11(ctx) {
2213
2940
  var upgradeCommand = {
2214
2941
  name: "upgrade",
2215
2942
  summary: "Bump @hogsend/* deps to latest + refresh vendored skills",
2216
- usage: usage11,
2217
- run: run11
2943
+ usage: usage13,
2944
+ run: run13
2218
2945
  };
2219
2946
 
2220
2947
  // src/commands/index.ts
@@ -2224,6 +2951,8 @@ var commands = [
2224
2951
  contactsCommand,
2225
2952
  statsCommand,
2226
2953
  eventsCommand,
2954
+ emailsCommand,
2955
+ campaignsCommand,
2227
2956
  studioCommand,
2228
2957
  setupCommand,
2229
2958
  skillsCommand,
@@ -2235,10 +2964,10 @@ var commands = [
2235
2964
  // src/lib/config.ts
2236
2965
  import { existsSync as existsSync8, readFileSync as readFileSync5 } from "fs";
2237
2966
  import { join as join8 } from "path";
2238
- import { parseArgs as parseArgs12 } from "util";
2967
+ import { parseArgs as parseArgs14 } from "util";
2239
2968
  var DEFAULT_BASE_URL = "http://localhost:3002";
2240
2969
  function parseGlobalFlags(argv) {
2241
- const { values, tokens } = parseArgs12({
2970
+ const { values, tokens } = parseArgs14({
2242
2971
  args: argv,
2243
2972
  allowPositionals: true,
2244
2973
  strict: false,
@@ -2246,11 +2975,12 @@ function parseGlobalFlags(argv) {
2246
2975
  options: {
2247
2976
  url: { type: "string" },
2248
2977
  "admin-key": { type: "string" },
2978
+ "data-key": { type: "string" },
2249
2979
  json: { type: "boolean", default: false },
2250
2980
  help: { type: "boolean", short: "h", default: false }
2251
2981
  }
2252
2982
  });
2253
- const owned = /* @__PURE__ */ new Set(["url", "admin-key", "json", "help", "h"]);
2983
+ const owned = /* @__PURE__ */ new Set(["url", "admin-key", "data-key", "json", "help", "h"]);
2254
2984
  const rest = [];
2255
2985
  for (const token of tokens) {
2256
2986
  if (token.kind === "positional") {
@@ -2268,6 +2998,7 @@ function parseGlobalFlags(argv) {
2268
2998
  return {
2269
2999
  url: typeof values.url === "string" ? values.url : void 0,
2270
3000
  adminKey: typeof values["admin-key"] === "string" ? values["admin-key"] : void 0,
3001
+ dataKey: typeof values["data-key"] === "string" ? values["data-key"] : void 0,
2271
3002
  json: values.json === true,
2272
3003
  help: values.help === true,
2273
3004
  rest
@@ -2303,9 +3034,11 @@ function resolveConfig(flags, cwd = process.cwd()) {
2303
3034
  const dotenv = loadDotEnv(cwd);
2304
3035
  const baseUrlRaw = flags.url ?? process.env.HOGSEND_API_URL ?? dotenv.HOGSEND_API_URL ?? DEFAULT_BASE_URL;
2305
3036
  const adminKey = flags.adminKey ?? process.env.HOGSEND_ADMIN_KEY ?? process.env.ADMIN_API_KEY ?? dotenv.HOGSEND_ADMIN_KEY ?? dotenv.ADMIN_API_KEY;
3037
+ const dataKey = flags.dataKey ?? process.env.HOGSEND_DATA_KEY ?? process.env.HOGSEND_API_KEY ?? dotenv.HOGSEND_DATA_KEY ?? dotenv.HOGSEND_API_KEY;
2306
3038
  return {
2307
3039
  baseUrl: baseUrlRaw.replace(/\/+$/, ""),
2308
- adminKey: adminKey && adminKey.length > 0 ? adminKey : void 0
3040
+ adminKey: adminKey && adminKey.length > 0 ? adminKey : void 0,
3041
+ dataKey: dataKey && dataKey.length > 0 ? dataKey : void 0
2309
3042
  };
2310
3043
  }
2311
3044
 
@@ -2332,6 +3065,7 @@ ${list}
2332
3065
  ${color.dim("Global options:")}
2333
3066
  --url <baseUrl> Target instance (default HOGSEND_API_URL or http://localhost:3002)
2334
3067
  --admin-key <key> Admin bearer token (default HOGSEND_ADMIN_KEY / ADMIN_API_KEY)
3068
+ --data-key <key> Ingest bearer token for writes (default HOGSEND_DATA_KEY / HOGSEND_API_KEY)
2335
3069
  --json Emit machine-readable JSON only (for agents)
2336
3070
  -h, --help Show help (use after a command for command help)
2337
3071
  -v, --version Show version
@@ -2372,10 +3106,12 @@ ${rootUsage()}
2372
3106
  }
2373
3107
  const cfg = resolveConfig(flags);
2374
3108
  const http = createAdminClient(cfg);
3109
+ const dataHttp = createDataPlaneClient(cfg);
2375
3110
  await command.run({
2376
3111
  argv: flags.rest,
2377
3112
  cfg,
2378
3113
  http,
3114
+ dataHttp,
2379
3115
  out,
2380
3116
  json: flags.json
2381
3117
  });