@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.
@@ -5,12 +5,14 @@ import type { Command, CommandContext } from "./types.js";
5
5
 
6
6
  const usage = `hogsend contacts <subcommand> [options]
7
7
 
8
- Inspect contacts via the running app's admin API (/v1/admin/contacts).
8
+ Inspect contacts via the admin API (/v1/admin/contacts) and upsert them via the
9
+ data plane (PUT /v1/contacts).
9
10
 
10
11
  Subcommands:
11
12
  list List contacts (newest activity first).
12
13
  get <id> Get one contact (by id or externalId) + preferences.
13
14
  timeline <id> Merged event/email/journey activity for a contact.
15
+ upsert Create or update a contact (PUT /v1/contacts).
14
16
 
15
17
  list options:
16
18
  --search <q> Filter by email/externalId substring.
@@ -22,12 +24,24 @@ timeline options:
22
24
  --limit <n> Page size (1-100, default 50).
23
25
  --offset <n> Page offset (default 0).
24
26
 
25
- Global options (handled by the router): --url, --admin-key, --json, -h/--help.
27
+ upsert options (at least one of --email / --user-id required):
28
+ --email <addr> Contact email (a resolvable identity key).
29
+ --user-id <id> External (distinct) id.
30
+ --prop <key=value> Contact property; repeatable. Value parsed as JSON,
31
+ falling back to a string. Uses the data plane (ingest key).
32
+ --props <json> Contact properties as one JSON object (merged with --prop).
33
+ --list <id> Subscribe to a list; repeatable.
34
+ --unlist <id> Unsubscribe from a list; repeatable.
35
+
36
+ Global options (handled by the router): --url, --admin-key, --data-key, --json,
37
+ -h/--help.
26
38
 
27
39
  Examples:
28
40
  hogsend contacts list --search acme@ --json
29
41
  hogsend contacts get user_123
30
- hogsend contacts timeline user_123 --type email --json`;
42
+ hogsend contacts timeline user_123 --type email --json
43
+ hogsend contacts upsert --email a@b.com --user-id user_123 --prop plan=pro
44
+ hogsend contacts upsert --user-id user_123 --props '{"plan":"pro","seats":5}'`;
31
45
 
32
46
  type ContactRecord = {
33
47
  id: string;
@@ -75,8 +89,94 @@ type TimelineResponse = {
75
89
  offset: number;
76
90
  };
77
91
 
92
+ /** Shape returned by PUT /v1/contacts. */
93
+ type UpsertResponse = {
94
+ id: string;
95
+ created: boolean;
96
+ linked: boolean;
97
+ };
98
+
78
99
  const badge = `${color.bgMagenta(color.black(" hogsend "))} contacts`;
79
100
 
101
+ /**
102
+ * Parse `--prop key=value` (repeatable) + an optional `--props <json>` object
103
+ * into a single properties record. Each `--prop` value is JSON-parsed when it
104
+ * is valid JSON (numbers/booleans/null/objects), else kept as a string. The
105
+ * explicit `--props` object is applied first, so later `--prop` flags win.
106
+ */
107
+ function parseProps(
108
+ ctx: CommandContext,
109
+ propsJson: string | undefined,
110
+ propPairs: string[] | undefined,
111
+ ): Record<string, unknown> | undefined {
112
+ const out: Record<string, unknown> = {};
113
+ let any = false;
114
+
115
+ if (propsJson !== undefined) {
116
+ let parsed: unknown;
117
+ try {
118
+ parsed = JSON.parse(propsJson);
119
+ } catch {
120
+ ctx.out.fail(`--props must be valid JSON, got: ${propsJson}`);
121
+ }
122
+ if (
123
+ parsed === null ||
124
+ typeof parsed !== "object" ||
125
+ Array.isArray(parsed)
126
+ ) {
127
+ ctx.out.fail("--props must be a JSON object");
128
+ }
129
+ Object.assign(out, parsed as Record<string, unknown>);
130
+ any = true;
131
+ }
132
+
133
+ for (const pair of propPairs ?? []) {
134
+ const eq = pair.indexOf("=");
135
+ if (eq === -1) {
136
+ ctx.out.fail(`--prop must be key=value, got: ${pair}`);
137
+ }
138
+ const key = pair.slice(0, eq).trim();
139
+ if (key === "") {
140
+ ctx.out.fail(`--prop key cannot be empty, got: ${pair}`);
141
+ }
142
+ const raw = pair.slice(eq + 1);
143
+ out[key] = coerceValue(raw);
144
+ any = true;
145
+ }
146
+
147
+ return any ? out : undefined;
148
+ }
149
+
150
+ /** JSON-parse a flag value, falling back to the raw string. */
151
+ function coerceValue(raw: string): unknown {
152
+ try {
153
+ return JSON.parse(raw);
154
+ } catch {
155
+ return raw;
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Build a `lists` map from repeatable `--list <id>` (true) / `--unlist <id>`
161
+ * (false) flags. Returns undefined when neither was passed.
162
+ */
163
+ function parseLists(
164
+ subscribe: string[] | undefined,
165
+ unsubscribe: string[] | undefined,
166
+ ): Record<string, boolean> | undefined {
167
+ const out: Record<string, boolean> = {};
168
+ let any = false;
169
+ for (const id of subscribe ?? []) {
170
+ out[id] = true;
171
+ any = true;
172
+ }
173
+ for (const id of unsubscribe ?? []) {
174
+ out[id] = false;
175
+ any = true;
176
+ }
177
+ return any ? out : undefined;
178
+ }
179
+
80
180
  /** Run an HTTP call, mapping HttpError into a clean ctx.out.fail message. */
81
181
  async function fetchOrFail<T>(
82
182
  ctx: CommandContext,
@@ -286,6 +386,73 @@ function summarizeTimelineEntry(entry: TimelineEntry): string {
286
386
  return `${subject} [${String(d.status ?? "")}]`;
287
387
  }
288
388
 
389
+ async function runUpsert(ctx: CommandContext, argv: string[]): Promise<void> {
390
+ const { values } = parseArgs({
391
+ args: argv,
392
+ allowPositionals: true,
393
+ options: {
394
+ email: { type: "string" },
395
+ "user-id": { type: "string" },
396
+ prop: { type: "string", multiple: true },
397
+ props: { type: "string" },
398
+ list: { type: "string", multiple: true },
399
+ unlist: { type: "string", multiple: true },
400
+ help: { type: "boolean", short: "h", default: false },
401
+ },
402
+ });
403
+
404
+ if (values.help) {
405
+ ctx.out.log(usage);
406
+ return;
407
+ }
408
+
409
+ const email = values.email;
410
+ const userId = values["user-id"];
411
+ if (!email && !userId) {
412
+ ctx.out.fail(
413
+ "contacts upsert requires at least one of --email or --user-id",
414
+ );
415
+ }
416
+
417
+ const properties = parseProps(ctx, values.props, values.prop);
418
+ const lists = parseLists(values.list, values.unlist);
419
+
420
+ const body: {
421
+ email?: string;
422
+ userId?: string;
423
+ properties?: Record<string, unknown>;
424
+ lists?: Record<string, boolean>;
425
+ } = {};
426
+ if (email) body.email = email;
427
+ if (userId) body.userId = userId;
428
+ if (properties) body.properties = properties;
429
+ if (lists) body.lists = lists;
430
+
431
+ if (!ctx.json) ctx.out.intro(`${badge} upsert`);
432
+
433
+ const res = await fetchOrFail<UpsertResponse>(ctx, "Upserting contact", () =>
434
+ ctx.dataHttp.put<UpsertResponse>("/v1/contacts", body),
435
+ );
436
+
437
+ if (ctx.json) {
438
+ ctx.out.json(res);
439
+ return;
440
+ }
441
+
442
+ ctx.out.kv(
443
+ {
444
+ id: res.id,
445
+ created: res.created,
446
+ linked: res.linked,
447
+ email: email ?? color.dim("(none)"),
448
+ userId: userId ?? color.dim("(none)"),
449
+ },
450
+ "Contact",
451
+ );
452
+ const verb = res.created ? "created" : "updated";
453
+ ctx.out.outro(`Contact ${color.cyan(res.id)} ${verb}.`);
454
+ }
455
+
289
456
  async function run(ctx: CommandContext): Promise<void> {
290
457
  const sub = ctx.argv[0];
291
458
 
@@ -296,21 +463,24 @@ async function run(ctx: CommandContext): Promise<void> {
296
463
  return runGet(ctx, ctx.argv);
297
464
  case "timeline":
298
465
  return runTimeline(ctx, ctx.argv);
466
+ case "upsert":
467
+ // Strip the leading "upsert" token; the rest is upsert's own flags.
468
+ return runUpsert(ctx, ctx.argv.slice(1));
299
469
  case undefined:
300
470
  ctx.out.fail(
301
- "contacts requires a subcommand: list, get, or timeline (see hogsend contacts --help)",
471
+ "contacts requires a subcommand: list, get, timeline, or upsert (see hogsend contacts --help)",
302
472
  );
303
473
  break;
304
474
  default:
305
475
  ctx.out.fail(
306
- `unknown contacts subcommand "${sub}" — expected list, get, or timeline`,
476
+ `unknown contacts subcommand "${sub}" — expected list, get, timeline, or upsert`,
307
477
  );
308
478
  }
309
479
  }
310
480
 
311
481
  export const contactsCommand: Command = {
312
482
  name: "contacts",
313
- summary: "List, inspect, and trace contact activity",
483
+ summary: "List, inspect, trace, and upsert contacts",
314
484
  usage,
315
485
  run,
316
486
  };
@@ -0,0 +1,231 @@
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 emails <subcommand> [options]
7
+
8
+ Send a transactional email through the data plane (POST /v1/emails). The send
9
+ runs through the full preferences + tracking pipeline (link-click + open).
10
+
11
+ Subcommands:
12
+ send <template> Send the named template to a recipient.
13
+
14
+ send options (at least one of --to / --user-id required):
15
+ --to <addr> Recipient email address.
16
+ --user-id <id> External (distinct) id; the recipient email is resolved
17
+ from the contact (404 if it has no resolvable email).
18
+ --prop <key=value> Template prop; repeatable. Value parsed as JSON, falling
19
+ back to a string.
20
+ --props <json> Template props as one JSON object (merged with --prop).
21
+ --from <addr> Override the default From address.
22
+ --subject <text> Override the rendered subject.
23
+ --reply-to <addr> Set the Reply-To address.
24
+ --category <key> Preference category / list id to gate the send on.
25
+ --skip-preference-check Bypass unsubscribe/suppression (requires full-admin).
26
+ --idempotency-key <k> Dedup key.
27
+
28
+ Global options (handled by the router): --url, --admin-key, --data-key, --json,
29
+ -h/--help.
30
+
31
+ Examples:
32
+ hogsend emails send welcome --to a@b.com --prop name=Ada
33
+ hogsend emails send welcome --user-id user_123 --props '{"name":"Ada"}' --json`;
34
+
35
+ /** Shape returned by POST /v1/emails. */
36
+ interface SendResponse {
37
+ emailSendId: string;
38
+ status: "queued" | "sent" | "suppressed" | "unsubscribed" | "skipped";
39
+ reason?: string;
40
+ }
41
+
42
+ const badge = `${color.bgMagenta(color.black(" hogsend "))} emails`;
43
+
44
+ /**
45
+ * Parse `--prop key=value` (repeatable) + an optional `--props <json>` object
46
+ * into a single props record. Each `--prop` value is JSON-parsed when valid
47
+ * JSON, else kept as a string. The `--props` object is applied first, so later
48
+ * `--prop` flags win.
49
+ */
50
+ function parseProps(
51
+ ctx: CommandContext,
52
+ propsJson: string | undefined,
53
+ propPairs: string[] | undefined,
54
+ ): Record<string, unknown> | undefined {
55
+ const out: Record<string, unknown> = {};
56
+ let any = false;
57
+
58
+ if (propsJson !== undefined) {
59
+ let parsed: unknown;
60
+ try {
61
+ parsed = JSON.parse(propsJson);
62
+ } catch {
63
+ ctx.out.fail(`--props must be valid JSON, got: ${propsJson}`);
64
+ }
65
+ if (
66
+ parsed === null ||
67
+ typeof parsed !== "object" ||
68
+ Array.isArray(parsed)
69
+ ) {
70
+ ctx.out.fail("--props must be a JSON object");
71
+ }
72
+ Object.assign(out, parsed as Record<string, unknown>);
73
+ any = true;
74
+ }
75
+
76
+ for (const pair of propPairs ?? []) {
77
+ const eq = pair.indexOf("=");
78
+ if (eq === -1) {
79
+ ctx.out.fail(`--prop must be key=value, got: ${pair}`);
80
+ }
81
+ const key = pair.slice(0, eq).trim();
82
+ if (key === "") {
83
+ ctx.out.fail(`--prop key cannot be empty, got: ${pair}`);
84
+ }
85
+ out[key] = coerceValue(pair.slice(eq + 1));
86
+ any = true;
87
+ }
88
+
89
+ return any ? out : undefined;
90
+ }
91
+
92
+ /** JSON-parse a flag value, falling back to the raw string. */
93
+ function coerceValue(raw: string): unknown {
94
+ try {
95
+ return JSON.parse(raw);
96
+ } catch {
97
+ return raw;
98
+ }
99
+ }
100
+
101
+ function statusColor(status: SendResponse["status"]): string {
102
+ switch (status) {
103
+ case "queued":
104
+ case "sent":
105
+ return color.green(status);
106
+ case "skipped":
107
+ return color.dim(status);
108
+ default:
109
+ // suppressed | unsubscribed
110
+ return color.yellow(status);
111
+ }
112
+ }
113
+
114
+ async function runSend(ctx: CommandContext, argv: string[]): Promise<void> {
115
+ const { values, positionals } = parseArgs({
116
+ args: argv,
117
+ allowPositionals: true,
118
+ options: {
119
+ to: { type: "string" },
120
+ "user-id": { type: "string" },
121
+ prop: { type: "string", multiple: true },
122
+ props: { type: "string" },
123
+ from: { type: "string" },
124
+ subject: { type: "string" },
125
+ "reply-to": { type: "string" },
126
+ category: { type: "string" },
127
+ "skip-preference-check": { type: "boolean", default: false },
128
+ "idempotency-key": { type: "string" },
129
+ help: { type: "boolean", short: "h", default: false },
130
+ },
131
+ });
132
+
133
+ if (values.help) {
134
+ ctx.out.log(usage);
135
+ return;
136
+ }
137
+
138
+ // positionals[0] is the template name (the "send" token was already stripped).
139
+ const template = positionals[0];
140
+ if (!template) {
141
+ ctx.out.fail(
142
+ "emails send requires a template, e.g. hogsend emails send welcome --to a@b.com",
143
+ );
144
+ }
145
+
146
+ const to = values.to;
147
+ const userId = values["user-id"];
148
+ if (!to && !userId) {
149
+ ctx.out.fail("emails send requires at least one of --to or --user-id");
150
+ }
151
+
152
+ const props = parseProps(ctx, values.props, values.prop);
153
+
154
+ const body: {
155
+ template: string;
156
+ to?: string;
157
+ userId?: string;
158
+ props?: Record<string, unknown>;
159
+ from?: string;
160
+ subject?: string;
161
+ replyTo?: string;
162
+ category?: string;
163
+ skipPreferenceCheck?: boolean;
164
+ idempotencyKey?: string;
165
+ } = { template };
166
+ if (to) body.to = to;
167
+ if (userId) body.userId = userId;
168
+ if (props) body.props = props;
169
+ if (values.from) body.from = values.from;
170
+ if (values.subject) body.subject = values.subject;
171
+ if (values["reply-to"]) body.replyTo = values["reply-to"];
172
+ if (values.category) body.category = values.category;
173
+ if (values["skip-preference-check"]) body.skipPreferenceCheck = true;
174
+ if (values["idempotency-key"]) {
175
+ body.idempotencyKey = values["idempotency-key"];
176
+ }
177
+
178
+ let res: SendResponse;
179
+ try {
180
+ res = await ctx.out.step(`Sending ${template}`, () =>
181
+ ctx.dataHttp.post<SendResponse>("/v1/emails", body),
182
+ );
183
+ } catch (error) {
184
+ if (isHttpError(error)) {
185
+ ctx.out.fail(error.message);
186
+ }
187
+ throw error;
188
+ }
189
+
190
+ if (ctx.json) {
191
+ ctx.out.json(res);
192
+ return;
193
+ }
194
+
195
+ ctx.out.intro(`${badge} send`);
196
+ ctx.out.kv(
197
+ {
198
+ emailSendId: res.emailSendId,
199
+ template,
200
+ recipient: to ?? userId ?? "",
201
+ status: statusColor(res.status),
202
+ reason: res.reason ?? "",
203
+ },
204
+ "Email send",
205
+ );
206
+ ctx.out.outro(`${template} → ${statusColor(res.status)}.`);
207
+ }
208
+
209
+ async function run(ctx: CommandContext): Promise<void> {
210
+ const sub = ctx.argv[0];
211
+
212
+ switch (sub) {
213
+ case "send":
214
+ // Strip the leading "send" token; the rest is send's own args.
215
+ return runSend(ctx, ctx.argv.slice(1));
216
+ case undefined:
217
+ ctx.out.fail(
218
+ "emails requires a subcommand: send (see hogsend emails --help)",
219
+ );
220
+ break;
221
+ default:
222
+ ctx.out.fail(`unknown emails subcommand "${sub}" — expected send`);
223
+ }
224
+ }
225
+
226
+ export const emailsCommand: Command = {
227
+ name: "emails",
228
+ summary: "Send a transactional email through the data plane",
229
+ usage,
230
+ run,
231
+ };