@hogsend/cli 0.11.0 → 0.12.1

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.
Files changed (32) hide show
  1. package/dist/bin.js +1985 -807
  2. package/dist/bin.js.map +1 -1
  3. package/package.json +4 -4
  4. package/skills/hogsend-integrate/SKILL.md +198 -0
  5. package/skills/hogsend-integrate/references/auth-billing-seams.md +199 -0
  6. package/skills/hogsend-integrate/references/framework-recipes.md +208 -0
  7. package/skills/hogsend-integrate/references/verification.md +86 -0
  8. package/skills/hogsend-migrate/SKILL.md +147 -0
  9. package/skills/hogsend-migrate/references/customerio-mapping.md +93 -0
  10. package/skills/hogsend-migrate/references/cutover-checklist.md +136 -0
  11. package/skills/hogsend-migrate/references/loops-mapping.md +132 -0
  12. package/skills/hogsend-migrate/references/resend-broadcasts-mapping.md +120 -0
  13. package/src/__tests__/dev.test.ts +323 -0
  14. package/src/__tests__/dns-apply.test.ts +297 -0
  15. package/src/__tests__/dns.test.ts +143 -0
  16. package/src/__tests__/domain-command.test.ts +216 -0
  17. package/src/__tests__/proc.test.ts +177 -0
  18. package/src/__tests__/setup-steps.test.ts +363 -0
  19. package/src/commands/dev.ts +444 -0
  20. package/src/commands/domain.ts +437 -0
  21. package/src/commands/events.ts +4 -1
  22. package/src/commands/index.ts +4 -0
  23. package/src/commands/setup.ts +34 -163
  24. package/src/lib/dns-apply.ts +218 -0
  25. package/src/lib/dns.ts +217 -0
  26. package/src/lib/proc.ts +189 -0
  27. package/src/lib/setup-steps.ts +333 -0
  28. package/studio/assets/index-CSXAjTbe.js +265 -0
  29. package/studio/assets/index-DCsT0fnT.css +1 -0
  30. package/studio/index.html +2 -2
  31. package/studio/assets/index-BBOTQnww.js +0 -250
  32. package/studio/assets/index-DnfpcXbb.css +0 -1
package/dist/bin.js CHANGED
@@ -819,30 +819,1031 @@ var contactsCommand = {
819
819
  run: run2
820
820
  };
821
821
 
822
+ // src/commands/dev.ts
823
+ import { spawnSync as spawnSync2 } from "child_process";
824
+ import { existsSync as existsSync3, readFileSync as readFileSync3 } from "fs";
825
+ import { join as join3, resolve } from "path";
826
+ import { parseArgs as parseArgs5 } from "util";
827
+
828
+ // src/lib/proc.ts
829
+ import { spawn } from "child_process";
830
+ import { createInterface } from "readline";
831
+ function spawnManaged(opts) {
832
+ const child = spawn(opts.cmd, opts.args, {
833
+ cwd: opts.cwd,
834
+ env: { ...process.env, FORCE_COLOR: "1", ...opts.env },
835
+ stdio: ["ignore", "pipe", "pipe"],
836
+ detached: process.platform !== "win32"
837
+ });
838
+ const prefix = opts.prefixColor(`[${opts.name}]`);
839
+ const writeOut = opts.sink ?? ((line2) => process.stdout.write(line2));
840
+ const writeErr = opts.sink ?? ((line2) => process.stderr.write(line2));
841
+ if (child.stdout) {
842
+ const rl = createInterface({ input: child.stdout });
843
+ rl.on("line", (line2) => writeOut(`${prefix} ${line2}
844
+ `));
845
+ }
846
+ if (child.stderr) {
847
+ const rl = createInterface({ input: child.stderr });
848
+ rl.on("line", (line2) => writeErr(`${prefix} ${line2}
849
+ `));
850
+ }
851
+ const callbacks = [];
852
+ let exitInfo = null;
853
+ const exited = new Promise((resolve3) => {
854
+ const settle = (info) => {
855
+ if (exitInfo) return;
856
+ exitInfo = info;
857
+ resolve3(info);
858
+ for (const cb of callbacks) cb(info);
859
+ };
860
+ child.once("close", (code, signal) => settle({ code, signal }));
861
+ child.once("error", (err) => {
862
+ writeErr(`${prefix} failed to start: ${err.message}
863
+ `);
864
+ settle({ code: null, signal: null });
865
+ });
866
+ });
867
+ return {
868
+ name: opts.name,
869
+ child,
870
+ exited,
871
+ onExit(cb) {
872
+ if (exitInfo) {
873
+ cb(exitInfo);
874
+ return;
875
+ }
876
+ callbacks.push(cb);
877
+ }
878
+ };
879
+ }
880
+ function hasExited(proc) {
881
+ return proc.child.pid === void 0 || proc.child.exitCode !== null || proc.child.signalCode !== null;
882
+ }
883
+ function killTree(proc, signal) {
884
+ const pid = proc.child.pid;
885
+ if (pid === void 0 || hasExited(proc)) return;
886
+ try {
887
+ if (process.platform !== "win32") {
888
+ process.kill(-pid, signal);
889
+ } else {
890
+ proc.child.kill(signal);
891
+ }
892
+ } catch {
893
+ try {
894
+ proc.child.kill(signal);
895
+ } catch {
896
+ }
897
+ }
898
+ }
899
+ function sleep(ms) {
900
+ return new Promise((resolve3) => setTimeout(resolve3, ms));
901
+ }
902
+ async function shutdownAll(procs, opts) {
903
+ if (procs.length === 0) return;
904
+ const timeoutMs = opts?.timeoutMs ?? 5e3;
905
+ for (const proc of procs) killTree(proc, "SIGTERM");
906
+ const allExited = Promise.all(procs.map((p) => p.exited));
907
+ await Promise.race([allExited, sleep(timeoutMs)]);
908
+ const stragglers = procs.filter((p) => !hasExited(p));
909
+ if (stragglers.length === 0) return;
910
+ for (const proc of stragglers) killTree(proc, "SIGKILL");
911
+ await Promise.race([allExited, sleep(2e3)]);
912
+ }
913
+ async function waitForHttp(url, timeoutMs) {
914
+ const deadline = Date.now() + timeoutMs;
915
+ let lastError = "no attempt completed";
916
+ do {
917
+ try {
918
+ const res = await fetch(url, { signal: AbortSignal.timeout(2e3) });
919
+ if (res.ok) return;
920
+ lastError = `last response: HTTP ${res.status}`;
921
+ } catch (err) {
922
+ lastError = `last error: ${err instanceof Error ? err.message : String(err)}`;
923
+ }
924
+ await sleep(500);
925
+ } while (Date.now() < deadline);
926
+ throw new Error(
927
+ `timed out after ${Math.round(timeoutMs / 1e3)}s waiting for ${url} (${lastError})`
928
+ );
929
+ }
930
+
931
+ // src/lib/setup-steps.ts
932
+ import { spawnSync } from "child_process";
933
+ import { randomBytes } from "crypto";
934
+ import { copyFileSync, existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync } from "fs";
935
+ import { connect } from "net";
936
+ import { join as join2 } from "path";
937
+
938
+ // src/lib/config.ts
939
+ import { existsSync, readFileSync } from "fs";
940
+ import { join } from "path";
941
+ import { parseArgs as parseArgs3 } from "util";
942
+ var DEFAULT_BASE_URL = "http://localhost:3002";
943
+ function parseGlobalFlags(argv) {
944
+ const { values: values2, tokens } = parseArgs3({
945
+ args: argv,
946
+ allowPositionals: true,
947
+ strict: false,
948
+ tokens: true,
949
+ options: {
950
+ url: { type: "string" },
951
+ "admin-key": { type: "string" },
952
+ "data-key": { type: "string" },
953
+ json: { type: "boolean", default: false },
954
+ help: { type: "boolean", short: "h", default: false }
955
+ }
956
+ });
957
+ const owned = /* @__PURE__ */ new Set(["url", "admin-key", "data-key", "json", "help", "h"]);
958
+ const rest = [];
959
+ for (const token of tokens) {
960
+ if (token.kind === "positional") {
961
+ rest.push(token.value);
962
+ } else if (token.kind === "option") {
963
+ if (owned.has(token.name)) continue;
964
+ rest.push(token.rawName);
965
+ if (token.value !== void 0 && !token.inlineValue) {
966
+ rest.push(token.value);
967
+ } else if (token.inlineValue && token.value !== void 0) {
968
+ rest[rest.length - 1] = `${token.rawName}=${token.value}`;
969
+ }
970
+ }
971
+ }
972
+ return {
973
+ url: typeof values2.url === "string" ? values2.url : void 0,
974
+ adminKey: typeof values2["admin-key"] === "string" ? values2["admin-key"] : void 0,
975
+ dataKey: typeof values2["data-key"] === "string" ? values2["data-key"] : void 0,
976
+ json: values2.json === true,
977
+ help: values2.help === true,
978
+ rest
979
+ };
980
+ }
981
+ function loadDotEnv(cwd = process.cwd()) {
982
+ const out = {};
983
+ const file = join(cwd, ".env");
984
+ if (!existsSync(file)) return out;
985
+ let raw;
986
+ try {
987
+ raw = readFileSync(file, "utf8");
988
+ } catch {
989
+ return out;
990
+ }
991
+ for (const rawLine of raw.split(/\r?\n/)) {
992
+ const line2 = rawLine.trim();
993
+ if (line2 === "" || line2.startsWith("#")) continue;
994
+ const withoutExport = line2.startsWith("export ") ? line2.slice("export ".length) : line2;
995
+ const eq2 = withoutExport.indexOf("=");
996
+ if (eq2 === -1) continue;
997
+ const key = withoutExport.slice(0, eq2).trim();
998
+ if (key === "") continue;
999
+ let value = withoutExport.slice(eq2 + 1).trim();
1000
+ if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
1001
+ value = value.slice(1, -1);
1002
+ }
1003
+ out[key] = value;
1004
+ }
1005
+ return out;
1006
+ }
1007
+ function resolveConfig(flags, cwd = process.cwd()) {
1008
+ const dotenv = loadDotEnv(cwd);
1009
+ const baseUrlRaw = flags.url ?? process.env.HOGSEND_API_URL ?? dotenv.HOGSEND_API_URL ?? DEFAULT_BASE_URL;
1010
+ const adminKey = flags.adminKey ?? process.env.HOGSEND_ADMIN_KEY ?? process.env.ADMIN_API_KEY ?? dotenv.HOGSEND_ADMIN_KEY ?? dotenv.ADMIN_API_KEY;
1011
+ const dataKey = flags.dataKey ?? process.env.HOGSEND_DATA_KEY ?? process.env.HOGSEND_API_KEY ?? dotenv.HOGSEND_DATA_KEY ?? dotenv.HOGSEND_API_KEY;
1012
+ return {
1013
+ baseUrl: baseUrlRaw.replace(/\/+$/, ""),
1014
+ adminKey: adminKey && adminKey.length > 0 ? adminKey : void 0,
1015
+ dataKey: dataKey && dataKey.length > 0 ? dataKey : void 0
1016
+ };
1017
+ }
1018
+
1019
+ // src/lib/setup-steps.ts
1020
+ var SECRET_KEY = "BETTER_AUTH_SECRET";
1021
+ var PLACEHOLDER_PREFIX = "change-me";
1022
+ var PLACEHOLDER_PREFIXES = [PLACEHOLDER_PREFIX, "REPLACE_ME"];
1023
+ function generateSecret() {
1024
+ return randomBytes(32).toString("hex");
1025
+ }
1026
+ var COMPOSE_FILES = [
1027
+ "docker-compose.yml",
1028
+ "docker-compose.yaml",
1029
+ "compose.yml",
1030
+ "compose.yaml"
1031
+ ];
1032
+ function hasComposeFile(cwd) {
1033
+ return COMPOSE_FILES.some((name) => existsSync2(join2(cwd, name)));
1034
+ }
1035
+ function readDotEnv(cwd) {
1036
+ return loadDotEnv(cwd);
1037
+ }
1038
+ function ensureEnvFile(cwd) {
1039
+ const envPath = join2(cwd, ".env");
1040
+ const examplePath = join2(cwd, ".env.example");
1041
+ if (existsSync2(envPath)) {
1042
+ return { step: "env", status: "skipped", detail: ".env already exists" };
1043
+ }
1044
+ if (existsSync2(examplePath)) {
1045
+ copyFileSync(examplePath, envPath);
1046
+ return {
1047
+ step: "env",
1048
+ status: "ok",
1049
+ detail: "copied .env.example -> .env"
1050
+ };
1051
+ }
1052
+ return {
1053
+ step: "env",
1054
+ status: "failed",
1055
+ detail: "no .env and no .env.example to copy from"
1056
+ };
1057
+ }
1058
+ function ensureAuthSecret(cwd) {
1059
+ const envPath = join2(cwd, ".env");
1060
+ if (!existsSync2(envPath)) {
1061
+ return { step: "secret", status: "skipped", detail: "skipped \u2014 no .env" };
1062
+ }
1063
+ let raw;
1064
+ try {
1065
+ raw = readFileSync2(envPath, "utf8");
1066
+ } catch (err) {
1067
+ return {
1068
+ step: "secret",
1069
+ status: "failed",
1070
+ detail: `could not read .env: ${err instanceof Error ? err.message : String(err)}`
1071
+ };
1072
+ }
1073
+ const lines = raw.split(/\r?\n/);
1074
+ const idx = lines.findIndex(
1075
+ (l) => l.replace(/^export\s+/, "").trimStart().startsWith(`${SECRET_KEY}=`)
1076
+ );
1077
+ const existingLine = idx === -1 ? void 0 : lines[idx];
1078
+ const current = existingLine === void 0 ? void 0 : existingLine.slice(existingLine.indexOf("=") + 1).trim();
1079
+ const isPlaceholder = current === void 0 || current === "" || PLACEHOLDER_PREFIXES.some((prefix) => current.startsWith(prefix));
1080
+ if (!isPlaceholder) {
1081
+ return {
1082
+ step: "secret",
1083
+ status: "skipped",
1084
+ detail: `${SECRET_KEY} already set`
1085
+ };
1086
+ }
1087
+ const secret = generateSecret();
1088
+ const newLine = `${SECRET_KEY}=${secret}`;
1089
+ if (idx === -1) {
1090
+ if (raw.length > 0 && !raw.endsWith("\n")) lines.push("");
1091
+ lines.push(newLine);
1092
+ } else {
1093
+ lines[idx] = newLine;
1094
+ }
1095
+ writeFileSync(envPath, lines.join("\n"));
1096
+ return {
1097
+ step: "secret",
1098
+ status: "ok",
1099
+ detail: `generated ${SECRET_KEY} (64-char hex)`
1100
+ };
1101
+ }
1102
+ function runCmd(cmd, args, cwd, quiet) {
1103
+ const result = spawnSync(cmd, args, {
1104
+ cwd,
1105
+ stdio: quiet ? "ignore" : "inherit"
1106
+ });
1107
+ return { status: result.status, ok: result.status === 0 };
1108
+ }
1109
+ async function dockerComposeUp(cwd, opts) {
1110
+ const result = runCmd(
1111
+ "docker",
1112
+ ["compose", "up", "-d"],
1113
+ cwd,
1114
+ opts?.quiet ?? false
1115
+ );
1116
+ return {
1117
+ step: "docker",
1118
+ status: result.ok ? "ok" : "failed",
1119
+ detail: result.ok ? "Postgres + Redis + Hatchet-Lite up" : `docker compose exited with code ${result.status ?? "?"}`
1120
+ };
1121
+ }
1122
+ async function runMigrations(cwd, opts) {
1123
+ const result = runCmd("pnpm", ["db:migrate"], cwd, opts?.quiet ?? false);
1124
+ return {
1125
+ step: "migrate",
1126
+ status: result.ok ? "ok" : "failed",
1127
+ detail: result.ok ? "engine + client migrations applied" : `pnpm db:migrate exited with code ${result.status ?? "?"}`
1128
+ };
1129
+ }
1130
+ function probeTcp(opts) {
1131
+ const { port, host = "127.0.0.1", timeoutMs = 750 } = opts;
1132
+ return new Promise((resolve3) => {
1133
+ let settled = false;
1134
+ const socket = connect({ port, host });
1135
+ const done = (ok) => {
1136
+ if (settled) return;
1137
+ settled = true;
1138
+ socket.destroy();
1139
+ resolve3(ok);
1140
+ };
1141
+ socket.once("connect", () => done(true));
1142
+ socket.once("error", () => done(false));
1143
+ socket.setTimeout(timeoutMs, () => done(false));
1144
+ });
1145
+ }
1146
+ function envPort(env, key, fallback) {
1147
+ const raw = env[key];
1148
+ if (raw === void 0) return fallback;
1149
+ const n = Number(raw);
1150
+ return Number.isInteger(n) && n > 0 && n < 65536 ? n : fallback;
1151
+ }
1152
+ function parseComposePs(stdout) {
1153
+ const entries = [];
1154
+ const push = (value) => {
1155
+ if (value === null || typeof value !== "object") return;
1156
+ const obj = value;
1157
+ const service = obj.Service ?? obj.Name;
1158
+ const state = obj.State;
1159
+ if (typeof service === "string" && typeof state === "string") {
1160
+ entries.push({ service, state: state.toLowerCase() });
1161
+ }
1162
+ };
1163
+ const trimmed = stdout.trim();
1164
+ if (trimmed === "") return entries;
1165
+ if (trimmed.startsWith("[")) {
1166
+ try {
1167
+ const arr = JSON.parse(trimmed);
1168
+ if (Array.isArray(arr)) for (const item of arr) push(item);
1169
+ } catch {
1170
+ }
1171
+ return entries;
1172
+ }
1173
+ for (const line2 of trimmed.split(/\r?\n/)) {
1174
+ try {
1175
+ push(JSON.parse(line2));
1176
+ } catch {
1177
+ }
1178
+ }
1179
+ return entries;
1180
+ }
1181
+ async function detectRunningInfra(cwd) {
1182
+ const found = { postgres: false, redis: false, hatchet: false };
1183
+ try {
1184
+ const result = spawnSync("docker", ["compose", "ps", "--format", "json"], {
1185
+ cwd,
1186
+ encoding: "utf8"
1187
+ });
1188
+ if (!result.error && result.status === 0 && typeof result.stdout === "string") {
1189
+ for (const entry of parseComposePs(result.stdout)) {
1190
+ if (entry.state !== "running") continue;
1191
+ if (entry.service === "postgres") {
1192
+ found.postgres = true;
1193
+ } else if (entry.service === "redis") {
1194
+ found.redis = true;
1195
+ } else if (entry.service.startsWith("hatchet") && !entry.service.includes("postgres")) {
1196
+ found.hatchet = true;
1197
+ }
1198
+ }
1199
+ }
1200
+ } catch {
1201
+ }
1202
+ if (found.postgres && found.redis && found.hatchet) return found;
1203
+ try {
1204
+ const env = readDotEnv(cwd);
1205
+ const [postgres2, redis, hatchet] = await Promise.all([
1206
+ found.postgres || probeTcp({ port: envPort(env, "POSTGRES_PORT", 5434) }),
1207
+ found.redis || probeTcp({ port: envPort(env, "REDIS_PORT", 6380) }),
1208
+ found.hatchet || probeTcp({ port: envPort(env, "HATCHET_DASHBOARD_PORT", 8888) })
1209
+ ]);
1210
+ return { postgres: postgres2, redis, hatchet };
1211
+ } catch {
1212
+ return found;
1213
+ }
1214
+ }
1215
+
1216
+ // src/commands/events.ts
1217
+ import { parseArgs as parseArgs4 } from "util";
1218
+ var usage3 = `hogsend events <userId> [options]
1219
+ hogsend events send <name> [options]
1220
+
1221
+ Read a single user's event history (admin API), or send an event into the data
1222
+ plane to drive journeys/buckets.
1223
+
1224
+ Read mode \u2014 hogsend events <userId>:
1225
+ Stream the event history for a single user, newest first. Wraps
1226
+ GET /v1/admin/events?userId=<userId>.
1227
+
1228
+ Arguments:
1229
+ <userId> The user (distinct) id to fetch events for. Required.
1230
+
1231
+ Options:
1232
+ --event <name> Filter to a single event name.
1233
+ --from <iso> Only events at/after this ISO-8601 timestamp.
1234
+ --to <iso> Only events at/before this ISO-8601 timestamp.
1235
+ --limit <n> Max events to return (1-100, default 50).
1236
+ --offset <n> Pagination offset (default 0).
1237
+
1238
+ Send mode \u2014 hogsend events send <name>:
1239
+ Push an event into POST /v1/events (data plane, ingest key). At least one of
1240
+ --email / --user-id is required.
1241
+
1242
+ Options:
1243
+ --email <addr> Recipient/identity email.
1244
+ --user-id <id> External (distinct) id.
1245
+ --prop <key=value> Event property; repeatable. Value parsed as JSON,
1246
+ falling back to a string.
1247
+ --props <json> Event properties as one JSON object.
1248
+ --contact-prop <k=v> Contact property to merge onto the contact; repeatable.
1249
+ --contact-props <json> Contact properties as one JSON object.
1250
+ --list <id> Subscribe to a list; repeatable.
1251
+ --unlist <id> Unsubscribe from a list; repeatable.
1252
+ --idempotency-key <k> Dedup key (sent as the Idempotency-Key header).
1253
+ --timestamp <iso> Override the event timestamp.
1254
+
1255
+ Global options (handled by the router): --url, --admin-key, --data-key, --json,
1256
+ -h/--help.
1257
+
1258
+ Examples:
1259
+ hogsend events user_123
1260
+ hogsend events user_123 --event signup --limit 10
1261
+ hogsend events user_123 --from 2026-01-01T00:00:00Z --json
1262
+ hogsend events send signup --user-id user_123 --prop plan=pro
1263
+ hogsend events send purchase --email a@b.com --props '{"amount":49}' --json`;
1264
+ async function run3(ctx) {
1265
+ if (ctx.argv[0] === "send") {
1266
+ return runSend2(ctx, ctx.argv.slice(1));
1267
+ }
1268
+ return runRead(ctx, ctx.argv);
1269
+ }
1270
+ async function runRead(ctx, argv) {
1271
+ const { values: values2, positionals } = parseArgs4({
1272
+ args: argv,
1273
+ allowPositionals: true,
1274
+ options: {
1275
+ event: { type: "string" },
1276
+ from: { type: "string" },
1277
+ to: { type: "string" },
1278
+ limit: { type: "string" },
1279
+ offset: { type: "string" },
1280
+ help: { type: "boolean", short: "h", default: false }
1281
+ }
1282
+ });
1283
+ if (values2.help) {
1284
+ ctx.out.log(usage3);
1285
+ return;
1286
+ }
1287
+ const userId = positionals[0];
1288
+ if (!userId) {
1289
+ ctx.out.fail("events requires a userId, e.g. hogsend events user_123");
1290
+ }
1291
+ const limit = parseNumber(values2.limit, "limit", ctx);
1292
+ const offset = parseNumber(values2.offset, "offset", ctx);
1293
+ const query = {
1294
+ userId,
1295
+ event: values2.event,
1296
+ from: values2.from,
1297
+ to: values2.to,
1298
+ limit,
1299
+ offset
1300
+ };
1301
+ let data;
1302
+ try {
1303
+ data = await ctx.out.step(
1304
+ `Fetching events for ${userId}`,
1305
+ () => ctx.http.get("/v1/admin/events", query)
1306
+ );
1307
+ } catch (error) {
1308
+ if (isHttpError(error)) {
1309
+ ctx.out.fail(error.message);
1310
+ }
1311
+ throw error;
1312
+ }
1313
+ if (ctx.json) {
1314
+ ctx.out.json(data);
1315
+ return;
1316
+ }
1317
+ ctx.out.intro(`${color.bgMagenta(color.black(" hogsend "))} events`);
1318
+ if (data.events.length === 0) {
1319
+ ctx.out.note(
1320
+ `No events found for ${color.cyan(userId)}.`,
1321
+ "Empty event stream"
1322
+ );
1323
+ ctx.out.outro(color.dim("Nothing to show."));
1324
+ return;
1325
+ }
1326
+ const rows = data.events.map((e) => ({
1327
+ occurredAt: e.occurredAt,
1328
+ event: e.event,
1329
+ properties: summarizeProps(e.properties),
1330
+ id: e.id
1331
+ }));
1332
+ ctx.out.table(rows, ["occurredAt", "event", "properties", "id"]);
1333
+ const shown = data.events.length;
1334
+ const through = data.offset + shown;
1335
+ ctx.out.outro(
1336
+ `${color.green(String(shown))} event${shown === 1 ? "" : "s"} ` + color.dim(`(${data.offset + 1}-${through} of ${data.total})`)
1337
+ );
1338
+ }
1339
+ async function runSend2(ctx, argv) {
1340
+ const { values: values2, positionals } = parseArgs4({
1341
+ args: argv,
1342
+ allowPositionals: true,
1343
+ options: {
1344
+ email: { type: "string" },
1345
+ "user-id": { type: "string" },
1346
+ prop: { type: "string", multiple: true },
1347
+ props: { type: "string" },
1348
+ "contact-prop": { type: "string", multiple: true },
1349
+ "contact-props": { type: "string" },
1350
+ list: { type: "string", multiple: true },
1351
+ unlist: { type: "string", multiple: true },
1352
+ "idempotency-key": { type: "string" },
1353
+ timestamp: { type: "string" },
1354
+ help: { type: "boolean", short: "h", default: false }
1355
+ }
1356
+ });
1357
+ if (values2.help) {
1358
+ ctx.out.log(usage3);
1359
+ return;
1360
+ }
1361
+ const name = positionals[0];
1362
+ if (!name) {
1363
+ ctx.out.fail(
1364
+ "events send requires an event name, e.g. hogsend events send signup --user-id user_123"
1365
+ );
1366
+ }
1367
+ const email = values2.email;
1368
+ const userId = values2["user-id"];
1369
+ if (!email && !userId) {
1370
+ ctx.out.fail("events send requires at least one of --email or --user-id");
1371
+ }
1372
+ const eventProperties = parseProps3(ctx, values2.props, values2.prop, "prop");
1373
+ const contactProperties = parseProps3(
1374
+ ctx,
1375
+ values2["contact-props"],
1376
+ values2["contact-prop"],
1377
+ "contact-prop"
1378
+ );
1379
+ const lists = parseLists2(values2.list, values2.unlist);
1380
+ const body = { name };
1381
+ if (email) body.email = email;
1382
+ if (userId) body.userId = userId;
1383
+ if (eventProperties) body.eventProperties = eventProperties;
1384
+ if (contactProperties) body.contactProperties = contactProperties;
1385
+ if (lists) body.lists = lists;
1386
+ if (values2["idempotency-key"]) {
1387
+ body.idempotencyKey = values2["idempotency-key"];
1388
+ }
1389
+ if (values2.timestamp) body.timestamp = values2.timestamp;
1390
+ let res;
1391
+ try {
1392
+ res = await ctx.out.step(
1393
+ `Sending event ${name}`,
1394
+ () => ctx.dataHttp.post("/v1/events", body)
1395
+ );
1396
+ } catch (error) {
1397
+ if (isHttpError(error)) {
1398
+ ctx.out.fail(error.message);
1399
+ }
1400
+ throw error;
1401
+ }
1402
+ if (ctx.json) {
1403
+ ctx.out.json(res);
1404
+ return;
1405
+ }
1406
+ ctx.out.intro(`${color.bgMagenta(color.black(" hogsend "))} events send`);
1407
+ const exited = res.exits.filter((e) => e.exited);
1408
+ ctx.out.kv(
1409
+ {
1410
+ event: name,
1411
+ stored: res.stored,
1412
+ identity: email ?? userId ?? "",
1413
+ exits: res.exits.length,
1414
+ "journeys exited": exited.length
1415
+ },
1416
+ "Event sent"
1417
+ );
1418
+ if (exited.length > 0) {
1419
+ ctx.out.table(
1420
+ exited.map((e) => ({ journeyId: e.journeyId, stateId: e.stateId })),
1421
+ ["journeyId", "stateId"]
1422
+ );
1423
+ }
1424
+ ctx.out.outro(
1425
+ res.stored ? `${color.green("Stored")} ${name}.` : color.dim(`${name} was deduped (not stored).`)
1426
+ );
1427
+ }
1428
+ function parseProps3(ctx, json2, pairs, flagName) {
1429
+ const out = {};
1430
+ let any = false;
1431
+ if (json2 !== void 0) {
1432
+ let parsed;
1433
+ try {
1434
+ parsed = JSON.parse(json2);
1435
+ } catch {
1436
+ ctx.out.fail(`--${flagName}s must be valid JSON, got: ${json2}`);
1437
+ }
1438
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
1439
+ ctx.out.fail(`--${flagName}s must be a JSON object`);
1440
+ }
1441
+ Object.assign(out, parsed);
1442
+ any = true;
1443
+ }
1444
+ for (const pair of pairs ?? []) {
1445
+ const eq2 = pair.indexOf("=");
1446
+ if (eq2 === -1) {
1447
+ ctx.out.fail(`--${flagName} must be key=value, got: ${pair}`);
1448
+ }
1449
+ const key = pair.slice(0, eq2).trim();
1450
+ if (key === "") {
1451
+ ctx.out.fail(`--${flagName} key cannot be empty, got: ${pair}`);
1452
+ }
1453
+ out[key] = coerceValue3(pair.slice(eq2 + 1));
1454
+ any = true;
1455
+ }
1456
+ return any ? out : void 0;
1457
+ }
1458
+ function coerceValue3(raw) {
1459
+ try {
1460
+ return JSON.parse(raw);
1461
+ } catch {
1462
+ return raw;
1463
+ }
1464
+ }
1465
+ function parseLists2(subscribe, unsubscribe) {
1466
+ const out = {};
1467
+ let any = false;
1468
+ for (const id of subscribe ?? []) {
1469
+ out[id] = true;
1470
+ any = true;
1471
+ }
1472
+ for (const id of unsubscribe ?? []) {
1473
+ out[id] = false;
1474
+ any = true;
1475
+ }
1476
+ return any ? out : void 0;
1477
+ }
1478
+ function parseNumber(raw, name, ctx) {
1479
+ if (raw === void 0) return void 0;
1480
+ const n = Number(raw);
1481
+ if (!Number.isFinite(n)) {
1482
+ ctx.out.fail(`--${name} must be a number, got "${raw}"`);
1483
+ }
1484
+ return n;
1485
+ }
1486
+ function summarizeProps(props) {
1487
+ if (!props) return "";
1488
+ const keys = Object.keys(props);
1489
+ if (keys.length === 0) return "";
1490
+ const preview = JSON.stringify(props);
1491
+ return preview.length > 60 ? `${preview.slice(0, 57)}...` : preview;
1492
+ }
1493
+ var eventsCommand = {
1494
+ name: "events",
1495
+ summary: "Stream a user's event history, or send an event",
1496
+ usage: usage3,
1497
+ run: run3
1498
+ };
1499
+
1500
+ // src/commands/dev.ts
1501
+ var usage4 = `hogsend dev [options]
1502
+ hogsend dev --fire <event> [event-send options]
1503
+
1504
+ Run the full local stack for a Hogsend app from one command:
1505
+
1506
+ 1. infra detect running containers, docker compose up -d when needed
1507
+ 2. .env cp .env.example -> .env + generate BETTER_AUTH_SECRET
1508
+ 3. migrate pnpm db:migrate (when the app has the script)
1509
+ 4. spawn [api] pnpm run dev + [worker] hatchet worker dev
1510
+ (falls back to pnpm run worker:dev without hatchet CLI/config)
1511
+ 5. health wait for GET /v1/health, then print the local URLs
1512
+
1513
+ Ctrl+C stops everything \u2014 the API, the worker, and their whole process trees.
1514
+
1515
+ Options:
1516
+ --cwd <dir> Project root to run in (defaults to the current directory).
1517
+ --no-worker Start the API only (skip the worker process).
1518
+ --no-infra Skip the docker/.env/migrate steps (infra managed elsewhere).
1519
+ --fire <event> Don't boot anything \u2014 send a test event to the RUNNING
1520
+ instance via POST /v1/events (works from a second terminal
1521
+ while hogsend dev runs in the first). Accepts every
1522
+ \`hogsend events send\` option: --email, --user-id, --prop,
1523
+ --props, --contact-prop, --contact-props, --list, --unlist,
1524
+ --idempotency-key, --timestamp.
1525
+ -h, --help Show this help.
1526
+
1527
+ Examples:
1528
+ hogsend dev
1529
+ hogsend dev --cwd apps/api
1530
+ hogsend dev --no-worker
1531
+ hogsend dev --fire signup --email a@b.com --prop plan=pro`;
1532
+ function renderDomainLine(d) {
1533
+ if (d.testMode?.active) {
1534
+ const target = d.testMode.redirectTo ?? "(no redirect address)";
1535
+ const state = d.status?.state;
1536
+ const suffix = d.domain && state && state !== "verified" ? ` (domain ${state})` : "";
1537
+ return color.yellow(
1538
+ `Test mode active \u2014 emails redirect to ${target}${suffix}`
1539
+ );
1540
+ }
1541
+ if (d.domain && d.status) {
1542
+ const state = d.status.state === "verified" ? color.green("verified") : color.yellow(d.status.state);
1543
+ return `${color.dim("Domain")} ${d.domain} \u2014 ${state}`;
1544
+ }
1545
+ return null;
1546
+ }
1547
+ async function fetchDomainLine(ctx) {
1548
+ if (!ctx.cfg.adminKey) return null;
1549
+ try {
1550
+ const d = await ctx.http.get("/v1/admin/domain");
1551
+ if (d === null || typeof d !== "object") return null;
1552
+ return renderDomainLine(d);
1553
+ } catch {
1554
+ return null;
1555
+ }
1556
+ }
1557
+ function extractFire(argv) {
1558
+ for (let i = 0; i < argv.length; i++) {
1559
+ const token = argv[i];
1560
+ if (token === "--fire") {
1561
+ const next = argv[i + 1];
1562
+ if (next === void 0 || next.startsWith("-")) {
1563
+ return {
1564
+ error: "--fire requires an event name, e.g. hogsend dev --fire signup --email a@b.com"
1565
+ };
1566
+ }
1567
+ return {
1568
+ event: next,
1569
+ rest: [...argv.slice(0, i), ...argv.slice(i + 2)]
1570
+ };
1571
+ }
1572
+ if (token.startsWith("--fire=")) {
1573
+ const event = token.slice("--fire=".length);
1574
+ if (event === "") {
1575
+ return { error: "--fire requires an event name" };
1576
+ }
1577
+ return { event, rest: [...argv.slice(0, i), ...argv.slice(i + 1)] };
1578
+ }
1579
+ }
1580
+ return null;
1581
+ }
1582
+ async function runFire(ctx, event, rest) {
1583
+ if (rest.includes("-h") || rest.includes("--help")) {
1584
+ ctx.out.log(usage4);
1585
+ return;
1586
+ }
1587
+ try {
1588
+ const res = await fetch(`${ctx.cfg.baseUrl}/v1/health`, {
1589
+ signal: AbortSignal.timeout(2e3)
1590
+ });
1591
+ if (!res.ok) throw new Error(`health returned HTTP ${res.status}`);
1592
+ } catch (err) {
1593
+ const msg = err instanceof Error ? err.message : String(err);
1594
+ ctx.out.fail(
1595
+ `cannot reach ${ctx.cfg.baseUrl} \u2014 is hogsend dev running? (${msg})`
1596
+ );
1597
+ }
1598
+ await runSend2(ctx, [event, ...rest]);
1599
+ }
1600
+ function assertHogsendApp(cwd, ctx) {
1601
+ const pkgPath = join3(cwd, "package.json");
1602
+ if (!existsSync3(pkgPath)) {
1603
+ ctx.out.fail(
1604
+ `not a Hogsend app \u2014 no package.json in ${cwd}. Run inside a scaffolded app (pnpm dlx create-hogsend@latest) or pass --cwd <dir>.`
1605
+ );
1606
+ }
1607
+ let pkg;
1608
+ try {
1609
+ pkg = JSON.parse(readFileSync3(pkgPath, "utf8"));
1610
+ } catch (err) {
1611
+ ctx.out.fail(
1612
+ `could not parse ${pkgPath}: ${err instanceof Error ? err.message : String(err)}`
1613
+ );
1614
+ }
1615
+ const scripts = pkg.scripts ?? {};
1616
+ for (const script of ["dev", "worker:dev"]) {
1617
+ if (!scripts[script]) {
1618
+ ctx.out.fail(
1619
+ `not a runnable Hogsend app \u2014 package.json in ${cwd} has no "${script}" script. Scaffold one with pnpm dlx create-hogsend@latest, or pass --cwd <dir>.`
1620
+ );
1621
+ }
1622
+ }
1623
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
1624
+ if (!deps["@hogsend/engine"]) {
1625
+ ctx.out.fail(
1626
+ `not a Hogsend app \u2014 @hogsend/engine is not a dependency in ${pkgPath}. Scaffold one with pnpm dlx create-hogsend@latest, or pass --cwd <dir>.`
1627
+ );
1628
+ }
1629
+ return pkg;
1630
+ }
1631
+ function hatchetOnPath() {
1632
+ try {
1633
+ const result = spawnSync2("hatchet", ["--version"], { stdio: "ignore" });
1634
+ return !result.error && result.status === 0;
1635
+ } catch {
1636
+ return false;
1637
+ }
1638
+ }
1639
+ function parsePort(raw, fallback) {
1640
+ if (raw === void 0) return fallback;
1641
+ const n = Number(raw);
1642
+ return Number.isInteger(n) && n > 0 && n < 65536 ? n : fallback;
1643
+ }
1644
+ async function prepareInfra(ctx, cwd, pkg) {
1645
+ if (!hasComposeFile(cwd)) {
1646
+ ctx.out.log(
1647
+ color.dim(
1648
+ " no docker-compose file \u2014 skipping docker (infra managed elsewhere)"
1649
+ )
1650
+ );
1651
+ } else {
1652
+ const infra = await ctx.out.step(
1653
+ "Checking infra",
1654
+ () => detectRunningInfra(cwd)
1655
+ );
1656
+ if (infra.postgres && infra.redis && infra.hatchet) {
1657
+ ctx.out.log(
1658
+ color.dim(" infra already running \u2014 skipping docker compose up")
1659
+ );
1660
+ } else {
1661
+ const docker = await ctx.out.step(
1662
+ "Starting infra (docker compose up -d)",
1663
+ () => dockerComposeUp(cwd, { quiet: ctx.json })
1664
+ );
1665
+ if (docker.status === "failed") {
1666
+ ctx.out.fail(
1667
+ `${docker.detail}. Is Docker running? Start Docker Desktop (or your docker daemon) and re-run hogsend dev \u2014 or pass --no-infra when infra is managed elsewhere.`
1668
+ );
1669
+ }
1670
+ }
1671
+ }
1672
+ const envFile = ensureEnvFile(cwd);
1673
+ if (envFile.status === "failed") {
1674
+ ctx.out.fail(
1675
+ `${envFile.detail} \u2014 create a .env (or .env.example) in ${cwd} first.`
1676
+ );
1677
+ }
1678
+ const secret = ensureAuthSecret(cwd);
1679
+ ctx.out.log(color.dim(` env: ${envFile.detail} \xB7 ${secret.detail}`));
1680
+ if (pkg.scripts?.["db:migrate"]) {
1681
+ const migrate = await ctx.out.step(
1682
+ "Running migrations (pnpm db:migrate)",
1683
+ () => runMigrations(cwd, { quiet: ctx.json })
1684
+ );
1685
+ if (migrate.status === "failed") {
1686
+ ctx.out.fail(`${migrate.detail} \u2014 fix, then re-run hogsend dev.`);
1687
+ }
1688
+ } else {
1689
+ ctx.out.log(color.dim(" no db:migrate script \u2014 skipping migrations"));
1690
+ }
1691
+ }
1692
+ async function run4(ctx) {
1693
+ const fire = extractFire(ctx.argv);
1694
+ if (fire && "error" in fire) {
1695
+ ctx.out.fail(fire.error);
1696
+ }
1697
+ if (fire) {
1698
+ await runFire(ctx, fire.event, fire.rest);
1699
+ return;
1700
+ }
1701
+ const { values: values2 } = parseArgs5({
1702
+ args: ctx.argv,
1703
+ allowPositionals: true,
1704
+ options: {
1705
+ cwd: { type: "string" },
1706
+ "no-worker": { type: "boolean", default: false },
1707
+ "no-infra": { type: "boolean", default: false },
1708
+ help: { type: "boolean", short: "h", default: false }
1709
+ }
1710
+ });
1711
+ if (values2.help) {
1712
+ ctx.out.log(usage4);
1713
+ return;
1714
+ }
1715
+ const cwd = resolve(values2.cwd ?? process.cwd());
1716
+ const pkg = assertHogsendApp(cwd, ctx);
1717
+ ctx.out.intro(
1718
+ `${color.bgMagenta(color.black(" hogsend "))} ${color.dim("dev")}`
1719
+ );
1720
+ if (!values2["no-infra"]) {
1721
+ await prepareInfra(ctx, cwd, pkg);
1722
+ }
1723
+ const dotenv = readDotEnv(cwd);
1724
+ const port = parsePort(dotenv.PORT, 3002);
1725
+ const hatchetPort = parsePort(dotenv.HATCHET_DASHBOARD_PORT, 8888);
1726
+ const apiBase = `http://localhost:${port}`;
1727
+ if (await probeTcp({ port })) {
1728
+ ctx.out.fail(
1729
+ `port ${port} is already in use \u2014 is another dev server (or hogsend dev) already running? Stop it, or change PORT in ${join3(cwd, ".env")}.`
1730
+ );
1731
+ }
1732
+ const procs = [];
1733
+ let shuttingDown = false;
1734
+ const shutdown = async (code) => {
1735
+ shuttingDown = true;
1736
+ await shutdownAll(procs);
1737
+ process.exit(code);
1738
+ };
1739
+ process.once("SIGINT", () => {
1740
+ if (shuttingDown) return;
1741
+ ctx.out.log(`
1742
+ ${color.dim("Shutting down\u2026")}`);
1743
+ void shutdown(0);
1744
+ });
1745
+ process.once("SIGTERM", () => {
1746
+ if (shuttingDown) return;
1747
+ void shutdown(0);
1748
+ });
1749
+ procs.push(
1750
+ spawnManaged({
1751
+ name: "api",
1752
+ cmd: "pnpm",
1753
+ args: ["run", "dev"],
1754
+ cwd,
1755
+ prefixColor: color.cyan
1756
+ })
1757
+ );
1758
+ if (!values2["no-worker"]) {
1759
+ const useHatchetCli = existsSync3(join3(cwd, "hatchet.yaml")) && hatchetOnPath();
1760
+ const mode = useHatchetCli ? "hatchet worker dev" : "pnpm run worker:dev";
1761
+ ctx.out.log(color.dim(` worker mode: ${mode}`));
1762
+ procs.push(
1763
+ spawnManaged({
1764
+ name: "worker",
1765
+ cmd: useHatchetCli ? "hatchet" : "pnpm",
1766
+ args: useHatchetCli ? ["worker", "dev"] : ["run", "worker:dev"],
1767
+ cwd,
1768
+ prefixColor: color.magenta
1769
+ })
1770
+ );
1771
+ }
1772
+ for (const proc of procs) {
1773
+ proc.onExit(({ code }) => {
1774
+ if (shuttingDown) return;
1775
+ shuttingDown = true;
1776
+ ctx.out.log(
1777
+ color.red(
1778
+ `
1779
+ [${proc.name}] exited with code ${code ?? "?"} \u2014 shutting down.`
1780
+ )
1781
+ );
1782
+ void shutdownAll(procs).then(() => process.exit(code ?? 1));
1783
+ });
1784
+ }
1785
+ try {
1786
+ await ctx.out.step(
1787
+ "Waiting for API health",
1788
+ () => waitForHttp(`${apiBase}/v1/health`, 6e4)
1789
+ );
1790
+ } catch (err) {
1791
+ if (shuttingDown) return;
1792
+ shuttingDown = true;
1793
+ await shutdownAll(procs);
1794
+ const msg = err instanceof Error ? err.message : String(err);
1795
+ ctx.out.fail(
1796
+ `API did not become healthy: ${msg}. Check the [api] log lines above.`
1797
+ );
1798
+ }
1799
+ const domainLine = await fetchDomainLine(ctx);
1800
+ const lines = [
1801
+ `${color.green("\u25CF")} API ${color.cyan(apiBase)}`,
1802
+ `${color.green("\u25CF")} Studio ${color.cyan(`${apiBase}/studio`)}`,
1803
+ `${color.green("\u25CF")} Hatchet ${color.cyan(`http://localhost:${hatchetPort}`)}`,
1804
+ `${color.green("\u25CF")} Docs ${color.cyan("https://docs.hogsend.com")}`
1805
+ ];
1806
+ if (domainLine) lines.push("", domainLine);
1807
+ lines.push(
1808
+ "",
1809
+ `${color.dim("Fire a test event:")} hogsend dev --fire signup --email you@example.com`,
1810
+ color.dim("Press Ctrl+C to stop everything.")
1811
+ );
1812
+ ctx.out.note(lines.join("\n"), "hogsend dev");
1813
+ await new Promise(() => {
1814
+ });
1815
+ }
1816
+ var devCommand = {
1817
+ name: "dev",
1818
+ summary: "Run the full local stack: infra, API + worker, health, URLs",
1819
+ usage: usage4,
1820
+ run: run4
1821
+ };
1822
+
822
1823
  // src/commands/doctor.ts
823
- import { parseArgs as parseArgs3 } from "util";
1824
+ import { parseArgs as parseArgs6 } from "util";
824
1825
 
825
1826
  // src/lib/skills.ts
826
1827
  import {
827
1828
  cpSync,
828
- existsSync,
1829
+ existsSync as existsSync4,
829
1830
  mkdirSync,
830
1831
  readdirSync,
831
- readFileSync,
1832
+ readFileSync as readFileSync4,
832
1833
  statSync,
833
- writeFileSync
1834
+ writeFileSync as writeFileSync2
834
1835
  } from "fs";
835
1836
  import { createRequire } from "module";
836
- import { join } from "path";
1837
+ import { join as join4 } from "path";
837
1838
  import { fileURLToPath } from "url";
838
1839
  function bundledSkillsDir() {
839
1840
  return fileURLToPath(new URL("../skills", import.meta.url));
840
1841
  }
841
1842
  function installDir(cwd) {
842
- return join(cwd, ".claude", "skills");
1843
+ return join4(cwd, ".claude", "skills");
843
1844
  }
844
1845
  function stampPath(cwd) {
845
- return join(cwd, ".claude", ".hogsend-skills.json");
1846
+ return join4(cwd, ".claude", ".hogsend-skills.json");
846
1847
  }
847
1848
  function cliVersion() {
848
1849
  try {
@@ -855,14 +1856,14 @@ function cliVersion() {
855
1856
  }
856
1857
  function readFileSyncSafe(path) {
857
1858
  try {
858
- return readFileSync(path, "utf8");
1859
+ return readFileSync4(path, "utf8");
859
1860
  } catch {
860
1861
  return "";
861
1862
  }
862
1863
  }
863
1864
  function readFrontmatterField(skillDir, field) {
864
- const skillFile = join(skillDir, "SKILL.md");
865
- if (!existsSync(skillFile)) return "";
1865
+ const skillFile = join4(skillDir, "SKILL.md");
1866
+ if (!existsSync4(skillFile)) return "";
866
1867
  const raw = readFileSyncSafe(skillFile);
867
1868
  const fmMatch = raw.match(/^---\n([\s\S]*?)\n---/);
868
1869
  if (!fmMatch) return "";
@@ -877,22 +1878,22 @@ function readFrontmatterField(skillDir, field) {
877
1878
  }
878
1879
  function listBundledSkills(cwd) {
879
1880
  const dir = bundledSkillsDir();
880
- if (!existsSync(dir)) return [];
1881
+ if (!existsSync4(dir)) return [];
881
1882
  const target = installDir(cwd);
882
1883
  const entries = readdirSync(dir).filter((name) => {
883
- const full = join(dir, name);
884
- return statSync(full).isDirectory() && existsSync(join(full, "SKILL.md"));
1884
+ const full = join4(dir, name);
1885
+ return statSync(full).isDirectory() && existsSync4(join4(full, "SKILL.md"));
885
1886
  });
886
1887
  return entries.sort().map((name) => ({
887
1888
  name,
888
- description: readFrontmatterField(join(dir, name), "description"),
889
- installed: existsSync(join(target, name))
1889
+ description: readFrontmatterField(join4(dir, name), "description"),
1890
+ installed: existsSync4(join4(target, name))
890
1891
  }));
891
1892
  }
892
1893
  function copySkill(name, cwd, force) {
893
- const src = join(bundledSkillsDir(), name);
894
- const dest = join(installDir(cwd), name);
895
- const exists2 = existsSync(dest);
1894
+ const src = join4(bundledSkillsDir(), name);
1895
+ const dest = join4(installDir(cwd), name);
1896
+ const exists2 = existsSync4(dest);
896
1897
  if (exists2 && !force) {
897
1898
  return { name, installed: false, skipped: true, path: dest };
898
1899
  }
@@ -906,13 +1907,13 @@ function writeSkillsStamp(cwd, skills) {
906
1907
  skills: [...skills].sort(),
907
1908
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
908
1909
  };
909
- mkdirSync(join(cwd, ".claude"), { recursive: true });
910
- writeFileSync(stampPath(cwd), `${JSON.stringify(stamp, null, 2)}
1910
+ mkdirSync(join4(cwd, ".claude"), { recursive: true });
1911
+ writeFileSync2(stampPath(cwd), `${JSON.stringify(stamp, null, 2)}
911
1912
  `);
912
1913
  }
913
1914
  function readSkillsStamp(cwd) {
914
1915
  try {
915
- const parsed = JSON.parse(readFileSync(stampPath(cwd), "utf8"));
1916
+ const parsed = JSON.parse(readFileSync4(stampPath(cwd), "utf8"));
916
1917
  return parsed && typeof parsed.cliVersion === "string" ? parsed : null;
917
1918
  } catch {
918
1919
  return null;
@@ -952,7 +1953,7 @@ function skillsNudge(ctx) {
952
1953
  "Skills out of date"
953
1954
  );
954
1955
  }
955
- var usage3 = `hogsend doctor [--url <baseUrl>] [--admin-key <key>] [--json]
1956
+ var usage5 = `hogsend doctor [--url <baseUrl>] [--admin-key <key>] [--json]
956
1957
 
957
1958
  Probe a running Hogsend instance via GET /v1/health and report its health:
958
1959
  component status (database, redis), two-track schema state (engine + client),
@@ -994,8 +1995,8 @@ function trackLine(name, track) {
994
1995
  const required = track.required ?? color.dim("none");
995
1996
  return `${color.bold(name.padEnd(7))} applied ${applied} -> required ${required} ${sync}`;
996
1997
  }
997
- async function run3(ctx) {
998
- const { values: values2 } = parseArgs3({
1998
+ async function run5(ctx) {
1999
+ const { values: values2 } = parseArgs6({
999
2000
  args: ctx.argv,
1000
2001
  allowPositionals: true,
1001
2002
  options: {
@@ -1005,7 +2006,7 @@ async function run3(ctx) {
1005
2006
  strict: false
1006
2007
  });
1007
2008
  if (values2.help) {
1008
- ctx.out.log(usage3);
2009
+ ctx.out.log(usage5);
1009
2010
  return;
1010
2011
  }
1011
2012
  const { baseUrl } = ctx.http.cfg;
@@ -1066,49 +2067,687 @@ async function run3(ctx) {
1066
2067
  if (!ok) process.exit(1);
1067
2068
  return;
1068
2069
  }
1069
- const badge6 = `${color.bgMagenta(color.black(" hogsend "))} doctor`;
1070
- ctx.out.intro(badge6);
1071
- const verdictColor = verdict === "ok" ? color.green : verdict === "degraded" ? color.red : color.yellow;
1072
- const lines = [
1073
- `${verdictColor("\u25CF")} ${color.bold(verdict)}`,
1074
- color.dim(
1075
- `${baseUrl} v${health.version} up ${Math.round(health.uptime)}s`
1076
- ),
1077
- "",
1078
- color.bold("Components"),
1079
- ` database ${componentSymbol(health.components.database.status)}${health.components.database.latencyMs !== void 0 ? color.dim(` ${health.components.database.latencyMs}ms`) : ""}`,
1080
- ` redis ${componentSymbol(health.components.redis.status)}${health.components.redis.latencyMs !== void 0 ? color.dim(` ${health.components.redis.latencyMs}ms`) : ""}`,
1081
- "",
1082
- color.bold("Schema"),
1083
- ` ${trackLine("engine", health.schema.engine)}`,
1084
- ` ${trackLine("client", health.schema.client)}`
1085
- ];
1086
- ctx.out.note(lines.join("\n"), "Doctor");
1087
- skillsNudge(ctx);
1088
- if (ok) {
1089
- ctx.out.outro(color.green("doctor: ok"));
2070
+ const badge7 = `${color.bgMagenta(color.black(" hogsend "))} doctor`;
2071
+ ctx.out.intro(badge7);
2072
+ const verdictColor = verdict === "ok" ? color.green : verdict === "degraded" ? color.red : color.yellow;
2073
+ const lines = [
2074
+ `${verdictColor("\u25CF")} ${color.bold(verdict)}`,
2075
+ color.dim(
2076
+ `${baseUrl} v${health.version} up ${Math.round(health.uptime)}s`
2077
+ ),
2078
+ "",
2079
+ color.bold("Components"),
2080
+ ` database ${componentSymbol(health.components.database.status)}${health.components.database.latencyMs !== void 0 ? color.dim(` ${health.components.database.latencyMs}ms`) : ""}`,
2081
+ ` redis ${componentSymbol(health.components.redis.status)}${health.components.redis.latencyMs !== void 0 ? color.dim(` ${health.components.redis.latencyMs}ms`) : ""}`,
2082
+ "",
2083
+ color.bold("Schema"),
2084
+ ` ${trackLine("engine", health.schema.engine)}`,
2085
+ ` ${trackLine("client", health.schema.client)}`
2086
+ ];
2087
+ ctx.out.note(lines.join("\n"), "Doctor");
2088
+ skillsNudge(ctx);
2089
+ if (ok) {
2090
+ ctx.out.outro(color.green("doctor: ok"));
2091
+ return;
2092
+ }
2093
+ ctx.out.outro(verdictColor(`doctor: ${verdict}`));
2094
+ process.exit(1);
2095
+ }
2096
+ var doctorCommand = {
2097
+ name: "doctor",
2098
+ summary: "Probe a running instance's health (GET /v1/health)",
2099
+ usage: usage5,
2100
+ run: run5
2101
+ };
2102
+
2103
+ // src/commands/domain.ts
2104
+ import { parseArgs as parseArgs7 } from "util";
2105
+ import { confirm } from "@clack/prompts";
2106
+
2107
+ // src/lib/dns.ts
2108
+ import { resolveNs as nodeResolveNs } from "dns/promises";
2109
+ var DNS_HOSTS = {
2110
+ cloudflare: {
2111
+ id: "cloudflare",
2112
+ label: "Cloudflare",
2113
+ nsSuffixes: ["ns.cloudflare.com"],
2114
+ panelUrl: (domain) => `https://dash.cloudflare.com/?to=/:account/${domain}/dns/records`
2115
+ },
2116
+ vercel: {
2117
+ id: "vercel",
2118
+ label: "Vercel",
2119
+ nsSuffixes: ["vercel-dns.com"],
2120
+ panelUrl: (domain) => `https://vercel.com/dashboard/domains/${domain}`
2121
+ },
2122
+ route53: {
2123
+ id: "route53",
2124
+ label: "AWS Route 53",
2125
+ nsSuffixes: ["awsdns-"],
2126
+ panelUrl: () => "https://console.aws.amazon.com/route53/v2/hostedzones"
2127
+ },
2128
+ godaddy: {
2129
+ id: "godaddy",
2130
+ label: "GoDaddy",
2131
+ nsSuffixes: ["domaincontrol.com"],
2132
+ panelUrl: (domain) => `https://dcc.godaddy.com/control/portfolio/${domain}/settings`
2133
+ },
2134
+ namecheap: {
2135
+ id: "namecheap",
2136
+ label: "Namecheap",
2137
+ nsSuffixes: ["registrar-servers.com"],
2138
+ panelUrl: (domain) => `https://ap.www.namecheap.com/domains/domaincontrolpanel/${domain}/advancedns`
2139
+ },
2140
+ porkbun: {
2141
+ id: "porkbun",
2142
+ label: "Porkbun",
2143
+ nsSuffixes: ["porkbun.com"],
2144
+ panelUrl: (domain) => `https://porkbun.com/account/domain/${domain}`
2145
+ },
2146
+ google: {
2147
+ id: "google",
2148
+ label: "Google Domains",
2149
+ nsSuffixes: ["googledomains.com", "google.com"],
2150
+ panelUrl: () => "https://domains.google.com/registrar"
2151
+ },
2152
+ unknown: {
2153
+ id: "unknown",
2154
+ label: "your DNS host",
2155
+ nsSuffixes: [],
2156
+ panelUrl: (domain) => `https://${domain}`
2157
+ }
2158
+ };
2159
+ async function detectDnsHost(domain, opts = {}) {
2160
+ const resolveNs = opts.resolveNs ?? nodeResolveNs;
2161
+ const labels = domain.toLowerCase().split(".").filter(Boolean);
2162
+ for (let i = 0; i <= labels.length - 2; i++) {
2163
+ const candidate = labels.slice(i).join(".");
2164
+ let nameservers;
2165
+ try {
2166
+ nameservers = await resolveNs(candidate);
2167
+ } catch {
2168
+ continue;
2169
+ }
2170
+ if (nameservers.length === 0) continue;
2171
+ const lowered = nameservers.map((ns) => ns.toLowerCase());
2172
+ for (const host of Object.values(DNS_HOSTS)) {
2173
+ if (host.id === "unknown") continue;
2174
+ const matched = host.nsSuffixes.some(
2175
+ (suffix) => lowered.some((ns) => ns.includes(suffix))
2176
+ );
2177
+ if (matched) return host;
2178
+ }
2179
+ return DNS_HOSTS.unknown;
2180
+ }
2181
+ return DNS_HOSTS.unknown;
2182
+ }
2183
+ var RELATIVE_HOST_IDS = /* @__PURE__ */ new Set(["namecheap", "godaddy"]);
2184
+ function relativeName(name, domain) {
2185
+ const lowered = name.toLowerCase();
2186
+ const suffix = `.${domain.toLowerCase()}`;
2187
+ if (lowered === domain.toLowerCase()) return "@";
2188
+ if (lowered.endsWith(suffix))
2189
+ return name.slice(0, name.length - suffix.length);
2190
+ return name;
2191
+ }
2192
+ function renderTable2(rows, header) {
2193
+ const all = [header, ...rows];
2194
+ const widths = header.map(
2195
+ (_, col) => Math.max(...all.map((row) => (row[col] ?? "").length))
2196
+ );
2197
+ const line2 = (row) => row.map((cell, col) => cell.padEnd(widths[col] ?? 0)).join(" ").trimEnd();
2198
+ return [
2199
+ line2(header),
2200
+ line2(widths.map((w) => "-".repeat(w))),
2201
+ ...rows.map(line2)
2202
+ ].join("\n");
2203
+ }
2204
+ function hostGuidance(host, domain) {
2205
+ switch (host.id) {
2206
+ case "cloudflare":
2207
+ return [
2208
+ "Cloudflare: set Proxy status to DNS only (grey cloud) on every record \u2014",
2209
+ "proxied (orange-cloud) records break email verification."
2210
+ ];
2211
+ case "namecheap":
2212
+ case "godaddy":
2213
+ return [
2214
+ `${host.label}: enter the host RELATIVE to ${domain ?? "your domain"} (the table above is already relative).`
2215
+ ];
2216
+ case "vercel":
2217
+ return [
2218
+ "Vercel: add the records under the domain's DNS tab (they apply instantly)."
2219
+ ];
2220
+ default:
2221
+ return [
2222
+ "Add these records in your DNS host's panel exactly as shown, then run",
2223
+ "`hogsend domain check` to poll verification."
2224
+ ];
2225
+ }
2226
+ }
2227
+ function formatRecordsFor(host, records, opts = {}) {
2228
+ if (records.length === 0) {
2229
+ return "No DNS records reported by the provider yet.";
2230
+ }
2231
+ const relative2 = RELATIVE_HOST_IDS.has(host.id) && opts.domain !== void 0;
2232
+ const rows = records.map((r) => [
2233
+ r.type,
2234
+ relative2 && opts.domain ? relativeName(r.name, opts.domain) : r.name,
2235
+ r.value,
2236
+ r.priority !== void 0 ? String(r.priority) : "",
2237
+ r.status
2238
+ ]);
2239
+ const table = renderTable2(rows, [
2240
+ "type",
2241
+ "name",
2242
+ "value",
2243
+ "priority",
2244
+ "status"
2245
+ ]);
2246
+ return [table, "", ...hostGuidance(host, opts.domain)].join("\n");
2247
+ }
2248
+
2249
+ // src/lib/dns-apply.ts
2250
+ function canAutoApply(host, env) {
2251
+ switch (host) {
2252
+ case "cloudflare":
2253
+ return Boolean(env.CLOUDFLARE_API_TOKEN);
2254
+ case "vercel":
2255
+ return Boolean(env.VERCEL_TOKEN);
2256
+ default:
2257
+ return false;
2258
+ }
2259
+ }
2260
+ var CF_DUPLICATE_CODES = /* @__PURE__ */ new Set([81057, 81053]);
2261
+ function registrableDomain(domain) {
2262
+ const labels = domain.split(".").filter(Boolean);
2263
+ if (labels.length <= 2) return domain;
2264
+ return labels.slice(-2).join(".");
2265
+ }
2266
+ async function parseJson(res) {
2267
+ try {
2268
+ return await res.json();
2269
+ } catch {
2270
+ return void 0;
2271
+ }
2272
+ }
2273
+ async function applyCloudflare(opts, fetchImpl) {
2274
+ const result = { applied: [], skipped: [], errors: [] };
2275
+ const token = opts.env.CLOUDFLARE_API_TOKEN ?? "";
2276
+ const headers = {
2277
+ Authorization: `Bearer ${token}`,
2278
+ "Content-Type": "application/json"
2279
+ };
2280
+ const zoneName = registrableDomain(opts.domain);
2281
+ let zoneId;
2282
+ try {
2283
+ const res = await fetchImpl(
2284
+ `https://api.cloudflare.com/client/v4/zones?name=${encodeURIComponent(zoneName)}`,
2285
+ { headers }
2286
+ );
2287
+ const body = await parseJson(res);
2288
+ zoneId = body?.result?.[0]?.id;
2289
+ } catch (cause) {
2290
+ result.errors.push(
2291
+ `Cloudflare zone lookup failed: ${cause instanceof Error ? cause.message : String(cause)}`
2292
+ );
2293
+ return result;
2294
+ }
2295
+ if (!zoneId) {
2296
+ result.errors.push(
2297
+ `could not resolve a Cloudflare zone for ${zoneName} \u2014 is the domain on this account?`
2298
+ );
2299
+ return result;
2300
+ }
2301
+ for (const record of opts.records) {
2302
+ try {
2303
+ const res = await fetchImpl(
2304
+ `https://api.cloudflare.com/client/v4/zones/${zoneId}/dns_records`,
2305
+ {
2306
+ method: "POST",
2307
+ headers,
2308
+ body: JSON.stringify({
2309
+ type: record.type,
2310
+ name: record.name,
2311
+ content: record.value,
2312
+ ttl: record.ttl ?? 1,
2313
+ // 1 = automatic
2314
+ ...record.priority !== void 0 ? { priority: record.priority } : {},
2315
+ // NEVER proxy mail-verification records — orange-cloud breaks them.
2316
+ proxied: false
2317
+ })
2318
+ }
2319
+ );
2320
+ if (res.ok) {
2321
+ result.applied.push(record);
2322
+ continue;
2323
+ }
2324
+ const body = await parseJson(res);
2325
+ const codes = (body?.errors ?? []).map((e) => e.code ?? 0);
2326
+ if (codes.some((code) => CF_DUPLICATE_CODES.has(code))) {
2327
+ result.skipped.push(record);
2328
+ continue;
2329
+ }
2330
+ const message = body?.errors?.[0]?.message ?? `Cloudflare API status ${res.status}`;
2331
+ result.errors.push(`${record.type} ${record.name}: ${message}`);
2332
+ } catch (cause) {
2333
+ result.errors.push(
2334
+ `${record.type} ${record.name}: ${cause instanceof Error ? cause.message : String(cause)}`
2335
+ );
2336
+ }
2337
+ }
2338
+ return result;
2339
+ }
2340
+ async function applyVercel(opts, fetchImpl) {
2341
+ const result = { applied: [], skipped: [], errors: [] };
2342
+ const token = opts.env.VERCEL_TOKEN ?? "";
2343
+ const teamId = opts.env.VERCEL_TEAM_ID;
2344
+ const base = `https://api.vercel.com/v2/domains/${encodeURIComponent(opts.domain)}/records`;
2345
+ const url = teamId ? `${base}?teamId=${encodeURIComponent(teamId)}` : base;
2346
+ for (const record of opts.records) {
2347
+ try {
2348
+ const res = await fetchImpl(url, {
2349
+ method: "POST",
2350
+ headers: {
2351
+ Authorization: `Bearer ${token}`,
2352
+ "Content-Type": "application/json"
2353
+ },
2354
+ body: JSON.stringify({
2355
+ type: record.type,
2356
+ name: record.name,
2357
+ value: record.value,
2358
+ ...record.ttl !== void 0 ? { ttl: record.ttl } : {},
2359
+ ...record.priority !== void 0 ? { mxPriority: record.priority } : {}
2360
+ })
2361
+ });
2362
+ if (res.ok) {
2363
+ result.applied.push(record);
2364
+ continue;
2365
+ }
2366
+ const body = await parseJson(res);
2367
+ const code = body?.error?.code ?? "";
2368
+ const message = body?.error?.message ?? "";
2369
+ if (/duplicate/i.test(code) || /already exists/i.test(message)) {
2370
+ result.skipped.push(record);
2371
+ continue;
2372
+ }
2373
+ result.errors.push(
2374
+ `${record.type} ${record.name}: ${message || `Vercel API status ${res.status}`}`
2375
+ );
2376
+ } catch (cause) {
2377
+ result.errors.push(
2378
+ `${record.type} ${record.name}: ${cause instanceof Error ? cause.message : String(cause)}`
2379
+ );
2380
+ }
2381
+ }
2382
+ return result;
2383
+ }
2384
+ async function applyRecords(opts) {
2385
+ const fetchImpl = opts.fetchImpl ?? fetch;
2386
+ if (!canAutoApply(opts.host, opts.env)) {
2387
+ return {
2388
+ applied: [],
2389
+ skipped: [...opts.records],
2390
+ errors: [
2391
+ `auto-apply is not available for ${opts.host} \u2014 add the records manually in your DNS panel`
2392
+ ]
2393
+ };
2394
+ }
2395
+ if (opts.host === "cloudflare") return applyCloudflare(opts, fetchImpl);
2396
+ return applyVercel(opts, fetchImpl);
2397
+ }
2398
+
2399
+ // src/lib/prompt.ts
2400
+ import { cancel as cancel2, isCancel } from "@clack/prompts";
2401
+ function bail(value) {
2402
+ if (isCancel(value)) {
2403
+ cancel2("Cancelled.");
2404
+ process.exit(0);
2405
+ }
2406
+ return value;
2407
+ }
2408
+
2409
+ // src/commands/domain.ts
2410
+ var usage6 = `hogsend domain <subcommand> [options]
2411
+
2412
+ Manage the sending domain through the RUNNING instance's admin routes
2413
+ (/v1/admin/domain) \u2014 provider API keys never touch the CLI. Requires an admin
2414
+ key (--admin-key / HOGSEND_ADMIN_KEY).
2415
+
2416
+ Subcommands:
2417
+ add <domain> Register the domain with the email provider, then print
2418
+ the DNS records formatted for YOUR DNS host (detected
2419
+ via NS lookup) with a panel deep link. When a
2420
+ CLOUDFLARE_API_TOKEN / VERCEL_TOKEN is set, offers to
2421
+ apply the records automatically.
2422
+ check [<domain>] Trigger a provider verification pass, then poll status
2423
+ every 15s until verified (exit 0) or timeout (exit 1).
2424
+ status Show domain, provider, verification state, DNS records,
2425
+ and the test-mode banner.
2426
+
2427
+ add options:
2428
+ --apply Apply records via the DNS host API without prompting.
2429
+ --no-apply Never apply records (skip the prompt).
2430
+
2431
+ check options:
2432
+ --timeout <s> Give up after this many seconds (default 300).
2433
+ --once Poll exactly once; exit per the current state.
2434
+
2435
+ status options:
2436
+ --refresh Bypass the server-side cache (forces a provider call).
2437
+
2438
+ Global options (handled by the router): --url, --admin-key, --json, -h/--help.
2439
+
2440
+ Examples:
2441
+ hogsend domain add mysite.com
2442
+ hogsend domain add mysite.com --apply
2443
+ hogsend domain check --timeout 600
2444
+ hogsend domain status --json`;
2445
+ var badge3 = `${color.bgMagenta(color.black(" hogsend "))} domain`;
2446
+ var DOMAIN_RE = /^([a-z0-9]([a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}$/i;
2447
+ var POLL_INTERVAL_MS = 15e3;
2448
+ var sleep2 = (ms) => new Promise((resolve3) => setTimeout(resolve3, ms));
2449
+ function getStatus(ctx, opts = {}) {
2450
+ const query = opts.refresh ? { refresh: "true" } : void 0;
2451
+ return ctx.http.get("/v1/admin/domain", query);
2452
+ }
2453
+ async function providerLabel(ctx) {
2454
+ try {
2455
+ const status = await getStatus(ctx);
2456
+ return status.providerId;
2457
+ } catch {
2458
+ return "the active email provider";
2459
+ }
2460
+ }
2461
+ async function failUnsupported(ctx, err) {
2462
+ if (isHttpError(err) && err.status === 501) {
2463
+ const provider = await providerLabel(ctx);
2464
+ ctx.out.fail(
2465
+ `provider ${provider} does not support domain management \u2014 verify the domain in your provider's dashboard instead`
2466
+ );
2467
+ }
2468
+ throw err;
2469
+ }
2470
+ function recordTicks(records) {
2471
+ return records.map((r) => {
2472
+ const tick = r.status === "verified" ? color.green("\u2713") : r.status === "failed" ? color.red("\u2717") : color.yellow("\u2026");
2473
+ return ` ${tick} ${r.type.padEnd(5)} ${r.name} ${color.dim(r.status)}`;
2474
+ }).join("\n");
2475
+ }
2476
+ function renderStatus(ctx, status) {
2477
+ ctx.out.kv({
2478
+ domain: status.domain ?? "(not configured)",
2479
+ provider: status.providerId,
2480
+ supported: status.supported,
2481
+ state: status.status?.state ?? "n/a",
2482
+ checkedAt: status.status?.checkedAt ?? ""
2483
+ });
2484
+ const records = status.status?.records ?? [];
2485
+ if (records.length > 0) {
2486
+ ctx.out.log("");
2487
+ ctx.out.table(
2488
+ records.map((r) => ({
2489
+ type: r.type,
2490
+ name: r.name,
2491
+ value: r.value,
2492
+ priority: r.priority ?? "",
2493
+ purpose: r.purpose,
2494
+ status: r.status
2495
+ }))
2496
+ );
2497
+ }
2498
+ if (status.testMode.active) {
2499
+ ctx.out.log("");
2500
+ ctx.out.log(
2501
+ `${color.bgYellow(color.black(" TEST MODE "))} ${color.yellow(
2502
+ `all sends redirect to ${status.testMode.redirectTo ?? "(no redirect address!)"} \u2014 reason: ${status.testMode.reason ?? "unknown"}`
2503
+ )}`
2504
+ );
2505
+ if (!status.testMode.redirectTo) {
2506
+ ctx.out.log(
2507
+ color.dim(
2508
+ " set HOGSEND_TEST_EMAIL (or STUDIO_ADMIN_EMAIL) \u2014 sends are BLOCKED until one is configured"
2509
+ )
2510
+ );
2511
+ }
2512
+ } else if (status.status?.state === "verified") {
2513
+ ctx.out.log("");
2514
+ ctx.out.log(`${color.green("\u2713")} sends live`);
2515
+ }
2516
+ }
2517
+ async function runAdd(ctx, argv) {
2518
+ const { values: values2, positionals } = parseArgs7({
2519
+ args: argv,
2520
+ allowPositionals: true,
2521
+ options: {
2522
+ apply: { type: "boolean", default: false },
2523
+ "no-apply": { type: "boolean", default: false },
2524
+ help: { type: "boolean", short: "h", default: false }
2525
+ }
2526
+ });
2527
+ if (values2.help) {
2528
+ ctx.out.log(usage6);
2529
+ return;
2530
+ }
2531
+ const domain = positionals[0];
2532
+ if (!domain) {
2533
+ ctx.out.fail("missing <domain> \u2014 usage: hogsend domain add <domain>");
2534
+ }
2535
+ if (!DOMAIN_RE.test(domain)) {
2536
+ ctx.out.fail(`invalid domain "${domain}" (expected e.g. mysite.com)`);
2537
+ }
2538
+ ctx.out.intro(badge3);
2539
+ let status;
2540
+ try {
2541
+ status = await ctx.out.step(
2542
+ `Registering ${domain} with the provider`,
2543
+ () => ctx.http.post("/v1/admin/domain", { domain })
2544
+ );
2545
+ } catch (err) {
2546
+ return failUnsupported(ctx, err);
2547
+ }
2548
+ const records = status.status?.records ?? [];
2549
+ const host = await detectDnsHost(domain);
2550
+ let applyResult = null;
2551
+ const autoAvailable = canAutoApply(host.id, process.env);
2552
+ if (autoAvailable && records.length > 0) {
2553
+ const doApply = values2.apply ? true : values2["no-apply"] ? false : ctx.out.interactive ? bail(
2554
+ await confirm({
2555
+ message: `Apply these records via the ${host.label} API?`,
2556
+ initialValue: true
2557
+ })
2558
+ ) : false;
2559
+ if (doApply) {
2560
+ applyResult = await ctx.out.step(
2561
+ `Applying ${records.length} record(s) via ${host.label}`,
2562
+ () => applyRecords({ host: host.id, domain, records, env: process.env })
2563
+ );
2564
+ }
2565
+ }
2566
+ if (ctx.json) {
2567
+ ctx.out.json({
2568
+ status,
2569
+ dnsHost: host.id,
2570
+ panelUrl: host.panelUrl(domain),
2571
+ autoApplyAvailable: autoAvailable,
2572
+ applied: applyResult
2573
+ });
2574
+ return;
2575
+ }
2576
+ ctx.out.log("");
2577
+ ctx.out.log(formatRecordsFor(host, records, { domain }));
2578
+ ctx.out.log("");
2579
+ ctx.out.log(
2580
+ `${color.dim("DNS panel:")} ${color.cyan(host.panelUrl(domain))}`
2581
+ );
2582
+ if (applyResult) {
2583
+ ctx.out.log("");
2584
+ ctx.out.log(
2585
+ `${color.green("applied")} ${applyResult.applied.length} ${color.yellow("skipped")} ${applyResult.skipped.length} ${color.red("errors")} ${applyResult.errors.length}`
2586
+ );
2587
+ for (const error of applyResult.errors) {
2588
+ ctx.out.log(` ${color.red("\u2717")} ${error}`);
2589
+ }
2590
+ } else if (autoAvailable && records.length > 0) {
2591
+ ctx.out.log("");
2592
+ ctx.out.log(
2593
+ color.dim(
2594
+ `Auto-apply available \u2014 rerun with --apply to write them via ${host.label}.`
2595
+ )
2596
+ );
2597
+ }
2598
+ ctx.out.outro(
2599
+ `Records added? Run ${color.cyan("hogsend domain check")} to poll verification.`
2600
+ );
2601
+ }
2602
+ async function runCheck(ctx, argv) {
2603
+ const { values: values2, positionals } = parseArgs7({
2604
+ args: argv,
2605
+ allowPositionals: true,
2606
+ options: {
2607
+ timeout: { type: "string", default: "300" },
2608
+ once: { type: "boolean", default: false },
2609
+ help: { type: "boolean", short: "h", default: false }
2610
+ }
2611
+ });
2612
+ if (values2.help) {
2613
+ ctx.out.log(usage6);
2614
+ return;
2615
+ }
2616
+ const timeoutSecs = Number(values2.timeout);
2617
+ if (!Number.isFinite(timeoutSecs) || timeoutSecs <= 0) {
2618
+ ctx.out.fail(`invalid --timeout "${values2.timeout}" (expected seconds)`);
2619
+ }
2620
+ ctx.out.intro(badge3);
2621
+ const requested = positionals[0];
2622
+ if (requested) {
2623
+ const current = await getStatus(ctx);
2624
+ if (current.domain && current.domain !== requested.toLowerCase()) {
2625
+ ctx.out.log(
2626
+ color.yellow(
2627
+ `note: the instance's configured sending domain is ${current.domain}; checking that (not ${requested}). Set EMAIL_DOMAIN to change it.`
2628
+ )
2629
+ );
2630
+ }
2631
+ }
2632
+ try {
2633
+ await ctx.out.step(
2634
+ "Triggering provider verification",
2635
+ () => ctx.http.post("/v1/admin/domain/verify", {})
2636
+ );
2637
+ } catch (err) {
2638
+ if (isHttpError(err) && err.status === 400) {
2639
+ ctx.out.fail(
2640
+ "no sending domain configured \u2014 set EMAIL_DOMAIN (or EMAIL_FROM), or run `hogsend domain add <domain>` first"
2641
+ );
2642
+ }
2643
+ return failUnsupported(ctx, err);
2644
+ }
2645
+ const deadline = Date.now() + timeoutSecs * 1e3;
2646
+ for (; ; ) {
2647
+ const status = await getStatus(ctx, { refresh: true });
2648
+ const records = status.status?.records ?? [];
2649
+ if (records.length > 0) {
2650
+ ctx.out.log(recordTicks(records));
2651
+ }
2652
+ if (status.status?.state === "verified") {
2653
+ if (ctx.json) {
2654
+ ctx.out.json(status);
2655
+ return;
2656
+ }
2657
+ renderStatus(ctx, status);
2658
+ ctx.out.outro(`${color.green("Verified.")} ${status.domain} is live.`);
2659
+ return;
2660
+ }
2661
+ if (values2.once) {
2662
+ if (ctx.json) {
2663
+ ctx.out.json(status);
2664
+ process.exitCode = 1;
2665
+ return;
2666
+ }
2667
+ ctx.out.fail(
2668
+ `domain is ${status.status?.state ?? "not configured"} (not verified)`
2669
+ );
2670
+ }
2671
+ if (Date.now() + POLL_INTERVAL_MS > deadline) {
2672
+ ctx.out.fail(
2673
+ `timed out after ${timeoutSecs}s \u2014 DNS can take a while to propagate; rerun \`hogsend domain check\` later`
2674
+ );
2675
+ }
2676
+ ctx.out.log(
2677
+ color.dim(
2678
+ `state: ${status.status?.state ?? "unknown"} \u2014 polling again in 15s ...`
2679
+ )
2680
+ );
2681
+ await sleep2(POLL_INTERVAL_MS);
2682
+ }
2683
+ }
2684
+ async function runStatus2(ctx, argv) {
2685
+ const { values: values2 } = parseArgs7({
2686
+ args: argv,
2687
+ allowPositionals: false,
2688
+ options: {
2689
+ refresh: { type: "boolean", default: false },
2690
+ help: { type: "boolean", short: "h", default: false }
2691
+ }
2692
+ });
2693
+ if (values2.help) {
2694
+ ctx.out.log(usage6);
2695
+ return;
2696
+ }
2697
+ const status = await getStatus(ctx, { refresh: values2.refresh });
2698
+ if (ctx.json) {
2699
+ ctx.out.json(status);
1090
2700
  return;
1091
2701
  }
1092
- ctx.out.outro(verdictColor(`doctor: ${verdict}`));
1093
- process.exit(1);
2702
+ ctx.out.intro(badge3);
2703
+ renderStatus(ctx, status);
2704
+ if (!status.supported) {
2705
+ ctx.out.log("");
2706
+ ctx.out.log(
2707
+ color.dim(
2708
+ `provider ${status.providerId} does not support domain management \u2014 verify the domain in your provider's dashboard`
2709
+ )
2710
+ );
2711
+ }
2712
+ ctx.out.outro("Done.");
1094
2713
  }
1095
- var doctorCommand = {
1096
- name: "doctor",
1097
- summary: "Probe a running instance's health (GET /v1/health)",
1098
- usage: usage3,
1099
- run: run3
2714
+ async function run6(ctx) {
2715
+ const sub = ctx.argv[0];
2716
+ const rest = ctx.argv.slice(1);
2717
+ if (!sub || sub === "--help" || sub === "-h" || sub === "help") {
2718
+ ctx.out.log(usage6);
2719
+ return;
2720
+ }
2721
+ switch (sub) {
2722
+ case "add":
2723
+ return runAdd(ctx, rest);
2724
+ case "check":
2725
+ return runCheck(ctx, rest);
2726
+ case "status":
2727
+ return runStatus2(ctx, rest);
2728
+ default:
2729
+ ctx.out.fail(
2730
+ `unknown subcommand "${sub}" \u2014 expected add | check | status`
2731
+ );
2732
+ }
2733
+ }
2734
+ var domainCommand = {
2735
+ name: "domain",
2736
+ summary: "Set up + verify the sending domain (DNS records, auto-apply)",
2737
+ usage: usage6,
2738
+ run: run6
1100
2739
  };
1101
2740
 
1102
2741
  // src/commands/eject.ts
1103
- import { existsSync as existsSync3, realpathSync } from "fs";
2742
+ import { existsSync as existsSync6, realpathSync } from "fs";
1104
2743
  import { createRequire as createRequire2 } from "module";
1105
- import { dirname, join as join3, sep as sep2 } from "path";
1106
- import { parseArgs as parseArgs4 } from "util";
2744
+ import { dirname, join as join6, sep as sep2 } from "path";
2745
+ import { parseArgs as parseArgs8 } from "util";
1107
2746
 
1108
2747
  // src/eject.ts
1109
- import { existsSync as existsSync2 } from "fs";
2748
+ import { existsSync as existsSync5 } from "fs";
1110
2749
  import { cp, readFile, rm, stat, writeFile } from "fs/promises";
1111
- import { basename, join as join2, relative, sep } from "path";
2750
+ import { basename, join as join5, relative, sep } from "path";
1112
2751
  var EjectError = class extends Error {
1113
2752
  constructor(message) {
1114
2753
  super(message);
@@ -1133,8 +2772,8 @@ async function writePackageJson(file, value) {
1133
2772
  async function eject(opts) {
1134
2773
  const { pkg, consumerRoot, sourceDir, force = false } = opts;
1135
2774
  const vendorName = basename(pkg);
1136
- const vendorPath = join2(consumerRoot, "vendor", vendorName);
1137
- const consumerPkgPath = join2(consumerRoot, "package.json");
2775
+ const vendorPath = join5(consumerRoot, "vendor", vendorName);
2776
+ const consumerPkgPath = join5(consumerRoot, "package.json");
1138
2777
  const consumerPkg = await readPackageJson(consumerPkgPath);
1139
2778
  let depMap;
1140
2779
  let depSpecBefore;
@@ -1150,7 +2789,7 @@ async function eject(opts) {
1150
2789
  `${pkg} is not a dependency of the consumer package.json`
1151
2790
  );
1152
2791
  }
1153
- if (existsSync2(vendorPath)) {
2792
+ if (existsSync5(vendorPath)) {
1154
2793
  if (!force) {
1155
2794
  throw new EjectError(
1156
2795
  `vendor/${vendorName} already exists; pass --force to overwrite`
@@ -1178,7 +2817,7 @@ async function eject(opts) {
1178
2817
  }
1179
2818
  });
1180
2819
  copiedFiles = await countFiles(vendorPath);
1181
- const vendoredPkgPath = join2(vendorPath, "package.json");
2820
+ const vendoredPkgPath = join5(vendorPath, "package.json");
1182
2821
  const vendoredPkg = await readPackageJson(vendoredPkgPath);
1183
2822
  if (vendoredPkg.private === true) {
1184
2823
  delete vendoredPkg.private;
@@ -1201,7 +2840,7 @@ async function countFiles(dir) {
1201
2840
  let count = 0;
1202
2841
  const entries = await readdir(dir, { withFileTypes: true });
1203
2842
  for (const entry of entries) {
1204
- const full = join2(dir, entry.name);
2843
+ const full = join5(dir, entry.name);
1205
2844
  if (entry.isDirectory()) {
1206
2845
  count += await countFiles(full);
1207
2846
  } else if (entry.isFile()) {
@@ -1217,7 +2856,7 @@ async function countFiles(dir) {
1217
2856
  }
1218
2857
 
1219
2858
  // src/commands/eject.ts
1220
- var usage4 = `hogsend eject <package> [--force] [--cwd <dir>]
2859
+ var usage7 = `hogsend eject <package> [--force] [--cwd <dir>]
1221
2860
 
1222
2861
  Copy a @hogsend/* package's source into vendor/<name> and rewrite the consumer
1223
2862
  dependency to file:./vendor/<name>. Every other dependency keeps upgrading.
@@ -1229,8 +2868,8 @@ Options:
1229
2868
 
1230
2869
  After ejecting, run: pnpm install`;
1231
2870
  function resolveSourceDir(pkg, consumerRoot) {
1232
- const direct = join3(consumerRoot, "node_modules", pkg, "package.json");
1233
- if (existsSync3(direct)) {
2871
+ const direct = join6(consumerRoot, "node_modules", pkg, "package.json");
2872
+ if (existsSync6(direct)) {
1234
2873
  return dirname(realpathSync(direct));
1235
2874
  }
1236
2875
  const require2 = createRequire2(`${consumerRoot}${sep2}`);
@@ -1238,15 +2877,15 @@ function resolveSourceDir(pkg, consumerRoot) {
1238
2877
  const entry = require2.resolve(pkg);
1239
2878
  let dir = dirname(entry);
1240
2879
  while (dir !== dirname(dir)) {
1241
- if (existsSync3(join3(dir, "package.json"))) return dir;
2880
+ if (existsSync6(join6(dir, "package.json"))) return dir;
1242
2881
  dir = dirname(dir);
1243
2882
  }
1244
2883
  } catch {
1245
2884
  }
1246
2885
  return null;
1247
2886
  }
1248
- async function run4(ctx) {
1249
- const { values: values2, positionals } = parseArgs4({
2887
+ async function run7(ctx) {
2888
+ const { values: values2, positionals } = parseArgs8({
1250
2889
  args: ctx.argv,
1251
2890
  allowPositionals: true,
1252
2891
  options: {
@@ -1256,7 +2895,7 @@ async function run4(ctx) {
1256
2895
  }
1257
2896
  });
1258
2897
  if (values2.help) {
1259
- ctx.out.log(usage4);
2898
+ ctx.out.log(usage7);
1260
2899
  return;
1261
2900
  }
1262
2901
  const pkg = positionals[0];
@@ -1300,13 +2939,13 @@ async function run4(ctx) {
1300
2939
  var ejectCommand = {
1301
2940
  name: "eject",
1302
2941
  summary: "Vendor a @hogsend/* package into vendor/<name>",
1303
- usage: usage4,
1304
- run: run4
2942
+ usage: usage7,
2943
+ run: run7
1305
2944
  };
1306
2945
 
1307
2946
  // src/commands/emails.ts
1308
- import { parseArgs as parseArgs5 } from "util";
1309
- var usage5 = `hogsend emails <subcommand> [options]
2947
+ import { parseArgs as parseArgs9 } from "util";
2948
+ var usage8 = `hogsend emails <subcommand> [options]
1310
2949
 
1311
2950
  Send a transactional email through the data plane (POST /v1/emails). The send
1312
2951
  runs through the full preferences + tracking pipeline (link-click + open).
@@ -1334,330 +2973,106 @@ Global options (handled by the router): --url, --admin-key, --data-key, --json,
1334
2973
  Examples:
1335
2974
  hogsend emails send welcome --to a@b.com --prop name=Ada
1336
2975
  hogsend emails send welcome --user-id user_123 --props '{"name":"Ada"}' --json`;
1337
- var badge3 = `${color.bgMagenta(color.black(" hogsend "))} emails`;
1338
- function parseProps3(ctx, propsJson, propPairs) {
2976
+ var badge4 = `${color.bgMagenta(color.black(" hogsend "))} emails`;
2977
+ function parseProps4(ctx, propsJson, propPairs) {
1339
2978
  const out = {};
1340
2979
  let any = false;
1341
2980
  if (propsJson !== void 0) {
1342
2981
  let parsed;
1343
2982
  try {
1344
2983
  parsed = JSON.parse(propsJson);
1345
- } catch {
1346
- ctx.out.fail(`--props must be valid JSON, got: ${propsJson}`);
1347
- }
1348
- if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
1349
- ctx.out.fail("--props must be a JSON object");
1350
- }
1351
- Object.assign(out, parsed);
1352
- any = true;
1353
- }
1354
- for (const pair of propPairs ?? []) {
1355
- const eq2 = pair.indexOf("=");
1356
- if (eq2 === -1) {
1357
- ctx.out.fail(`--prop must be key=value, got: ${pair}`);
1358
- }
1359
- const key = pair.slice(0, eq2).trim();
1360
- if (key === "") {
1361
- ctx.out.fail(`--prop key cannot be empty, got: ${pair}`);
1362
- }
1363
- out[key] = coerceValue3(pair.slice(eq2 + 1));
1364
- any = true;
1365
- }
1366
- return any ? out : void 0;
1367
- }
1368
- function coerceValue3(raw) {
1369
- try {
1370
- return JSON.parse(raw);
1371
- } catch {
1372
- return raw;
1373
- }
1374
- }
1375
- function statusColor2(status) {
1376
- switch (status) {
1377
- case "queued":
1378
- case "sent":
1379
- return color.green(status);
1380
- case "skipped":
1381
- return color.dim(status);
1382
- default:
1383
- return color.yellow(status);
1384
- }
1385
- }
1386
- async function runSend2(ctx, argv) {
1387
- const { values: values2, positionals } = parseArgs5({
1388
- args: argv,
1389
- allowPositionals: true,
1390
- options: {
1391
- to: { type: "string" },
1392
- "user-id": { type: "string" },
1393
- prop: { type: "string", multiple: true },
1394
- props: { type: "string" },
1395
- from: { type: "string" },
1396
- subject: { type: "string" },
1397
- "reply-to": { type: "string" },
1398
- category: { type: "string" },
1399
- "skip-preference-check": { type: "boolean", default: false },
1400
- "idempotency-key": { type: "string" },
1401
- help: { type: "boolean", short: "h", default: false }
1402
- }
1403
- });
1404
- if (values2.help) {
1405
- ctx.out.log(usage5);
1406
- return;
1407
- }
1408
- const template = positionals[0];
1409
- if (!template) {
1410
- ctx.out.fail(
1411
- "emails send requires a template, e.g. hogsend emails send welcome --to a@b.com"
1412
- );
1413
- }
1414
- const to = values2.to;
1415
- const userId = values2["user-id"];
1416
- if (!to && !userId) {
1417
- ctx.out.fail("emails send requires at least one of --to or --user-id");
1418
- }
1419
- const props = parseProps3(ctx, values2.props, values2.prop);
1420
- const body = { template };
1421
- if (to) body.to = to;
1422
- if (userId) body.userId = userId;
1423
- if (props) body.props = props;
1424
- if (values2.from) body.from = values2.from;
1425
- if (values2.subject) body.subject = values2.subject;
1426
- if (values2["reply-to"]) body.replyTo = values2["reply-to"];
1427
- if (values2.category) body.category = values2.category;
1428
- if (values2["skip-preference-check"]) body.skipPreferenceCheck = true;
1429
- if (values2["idempotency-key"]) {
1430
- body.idempotencyKey = values2["idempotency-key"];
1431
- }
1432
- let res;
1433
- try {
1434
- res = await ctx.out.step(
1435
- `Sending ${template}`,
1436
- () => ctx.dataHttp.post("/v1/emails", body)
1437
- );
1438
- } catch (error) {
1439
- if (isHttpError(error)) {
1440
- ctx.out.fail(error.message);
1441
- }
1442
- throw error;
1443
- }
1444
- if (ctx.json) {
1445
- ctx.out.json(res);
1446
- return;
1447
- }
1448
- ctx.out.intro(`${badge3} send`);
1449
- ctx.out.kv(
1450
- {
1451
- emailSendId: res.emailSendId,
1452
- template,
1453
- recipient: to ?? userId ?? "",
1454
- status: statusColor2(res.status),
1455
- reason: res.reason ?? ""
1456
- },
1457
- "Email send"
1458
- );
1459
- ctx.out.outro(`${template} \u2192 ${statusColor2(res.status)}.`);
1460
- }
1461
- async function run5(ctx) {
1462
- const sub = ctx.argv[0];
1463
- switch (sub) {
1464
- case "send":
1465
- return runSend2(ctx, ctx.argv.slice(1));
1466
- case void 0:
1467
- ctx.out.fail(
1468
- "emails requires a subcommand: send (see hogsend emails --help)"
1469
- );
1470
- break;
1471
- default:
1472
- ctx.out.fail(`unknown emails subcommand "${sub}" \u2014 expected send`);
1473
- }
1474
- }
1475
- var emailsCommand = {
1476
- name: "emails",
1477
- summary: "Send a transactional email through the data plane",
1478
- usage: usage5,
1479
- run: run5
1480
- };
1481
-
1482
- // src/commands/events.ts
1483
- import { parseArgs as parseArgs6 } from "util";
1484
- var usage6 = `hogsend events <userId> [options]
1485
- hogsend events send <name> [options]
1486
-
1487
- Read a single user's event history (admin API), or send an event into the data
1488
- plane to drive journeys/buckets.
1489
-
1490
- Read mode \u2014 hogsend events <userId>:
1491
- Stream the event history for a single user, newest first. Wraps
1492
- GET /v1/admin/events?userId=<userId>.
1493
-
1494
- Arguments:
1495
- <userId> The user (distinct) id to fetch events for. Required.
1496
-
1497
- Options:
1498
- --event <name> Filter to a single event name.
1499
- --from <iso> Only events at/after this ISO-8601 timestamp.
1500
- --to <iso> Only events at/before this ISO-8601 timestamp.
1501
- --limit <n> Max events to return (1-100, default 50).
1502
- --offset <n> Pagination offset (default 0).
1503
-
1504
- Send mode \u2014 hogsend events send <name>:
1505
- Push an event into POST /v1/events (data plane, ingest key). At least one of
1506
- --email / --user-id is required.
1507
-
1508
- Options:
1509
- --email <addr> Recipient/identity email.
1510
- --user-id <id> External (distinct) id.
1511
- --prop <key=value> Event property; repeatable. Value parsed as JSON,
1512
- falling back to a string.
1513
- --props <json> Event properties as one JSON object.
1514
- --contact-prop <k=v> Contact property to merge onto the contact; repeatable.
1515
- --contact-props <json> Contact properties as one JSON object.
1516
- --list <id> Subscribe to a list; repeatable.
1517
- --unlist <id> Unsubscribe from a list; repeatable.
1518
- --idempotency-key <k> Dedup key (sent as the Idempotency-Key header).
1519
- --timestamp <iso> Override the event timestamp.
1520
-
1521
- Global options (handled by the router): --url, --admin-key, --data-key, --json,
1522
- -h/--help.
1523
-
1524
- Examples:
1525
- hogsend events user_123
1526
- hogsend events user_123 --event signup --limit 10
1527
- hogsend events user_123 --from 2026-01-01T00:00:00Z --json
1528
- hogsend events send signup --user-id user_123 --prop plan=pro
1529
- hogsend events send purchase --email a@b.com --props '{"amount":49}' --json`;
1530
- async function run6(ctx) {
1531
- if (ctx.argv[0] === "send") {
1532
- return runSend3(ctx, ctx.argv.slice(1));
1533
- }
1534
- return runRead(ctx, ctx.argv);
1535
- }
1536
- async function runRead(ctx, argv) {
1537
- const { values: values2, positionals } = parseArgs6({
1538
- args: argv,
1539
- allowPositionals: true,
1540
- options: {
1541
- event: { type: "string" },
1542
- from: { type: "string" },
1543
- to: { type: "string" },
1544
- limit: { type: "string" },
1545
- offset: { type: "string" },
1546
- help: { type: "boolean", short: "h", default: false }
2984
+ } catch {
2985
+ ctx.out.fail(`--props must be valid JSON, got: ${propsJson}`);
1547
2986
  }
1548
- });
1549
- if (values2.help) {
1550
- ctx.out.log(usage6);
1551
- return;
1552
- }
1553
- const userId = positionals[0];
1554
- if (!userId) {
1555
- ctx.out.fail("events requires a userId, e.g. hogsend events user_123");
2987
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
2988
+ ctx.out.fail("--props must be a JSON object");
2989
+ }
2990
+ Object.assign(out, parsed);
2991
+ any = true;
1556
2992
  }
1557
- const limit = parseNumber(values2.limit, "limit", ctx);
1558
- const offset = parseNumber(values2.offset, "offset", ctx);
1559
- const query = {
1560
- userId,
1561
- event: values2.event,
1562
- from: values2.from,
1563
- to: values2.to,
1564
- limit,
1565
- offset
1566
- };
1567
- let data;
1568
- try {
1569
- data = await ctx.out.step(
1570
- `Fetching events for ${userId}`,
1571
- () => ctx.http.get("/v1/admin/events", query)
1572
- );
1573
- } catch (error) {
1574
- if (isHttpError(error)) {
1575
- ctx.out.fail(error.message);
2993
+ for (const pair of propPairs ?? []) {
2994
+ const eq2 = pair.indexOf("=");
2995
+ if (eq2 === -1) {
2996
+ ctx.out.fail(`--prop must be key=value, got: ${pair}`);
1576
2997
  }
1577
- throw error;
2998
+ const key = pair.slice(0, eq2).trim();
2999
+ if (key === "") {
3000
+ ctx.out.fail(`--prop key cannot be empty, got: ${pair}`);
3001
+ }
3002
+ out[key] = coerceValue4(pair.slice(eq2 + 1));
3003
+ any = true;
1578
3004
  }
1579
- if (ctx.json) {
1580
- ctx.out.json(data);
1581
- return;
3005
+ return any ? out : void 0;
3006
+ }
3007
+ function coerceValue4(raw) {
3008
+ try {
3009
+ return JSON.parse(raw);
3010
+ } catch {
3011
+ return raw;
1582
3012
  }
1583
- ctx.out.intro(`${color.bgMagenta(color.black(" hogsend "))} events`);
1584
- if (data.events.length === 0) {
1585
- ctx.out.note(
1586
- `No events found for ${color.cyan(userId)}.`,
1587
- "Empty event stream"
1588
- );
1589
- ctx.out.outro(color.dim("Nothing to show."));
1590
- return;
3013
+ }
3014
+ function statusColor2(status) {
3015
+ switch (status) {
3016
+ case "queued":
3017
+ case "sent":
3018
+ return color.green(status);
3019
+ case "skipped":
3020
+ return color.dim(status);
3021
+ default:
3022
+ return color.yellow(status);
1591
3023
  }
1592
- const rows = data.events.map((e) => ({
1593
- occurredAt: e.occurredAt,
1594
- event: e.event,
1595
- properties: summarizeProps(e.properties),
1596
- id: e.id
1597
- }));
1598
- ctx.out.table(rows, ["occurredAt", "event", "properties", "id"]);
1599
- const shown = data.events.length;
1600
- const through = data.offset + shown;
1601
- ctx.out.outro(
1602
- `${color.green(String(shown))} event${shown === 1 ? "" : "s"} ` + color.dim(`(${data.offset + 1}-${through} of ${data.total})`)
1603
- );
1604
3024
  }
1605
3025
  async function runSend3(ctx, argv) {
1606
- const { values: values2, positionals } = parseArgs6({
3026
+ const { values: values2, positionals } = parseArgs9({
1607
3027
  args: argv,
1608
3028
  allowPositionals: true,
1609
3029
  options: {
1610
- email: { type: "string" },
3030
+ to: { type: "string" },
1611
3031
  "user-id": { type: "string" },
1612
3032
  prop: { type: "string", multiple: true },
1613
3033
  props: { type: "string" },
1614
- "contact-prop": { type: "string", multiple: true },
1615
- "contact-props": { type: "string" },
1616
- list: { type: "string", multiple: true },
1617
- unlist: { type: "string", multiple: true },
3034
+ from: { type: "string" },
3035
+ subject: { type: "string" },
3036
+ "reply-to": { type: "string" },
3037
+ category: { type: "string" },
3038
+ "skip-preference-check": { type: "boolean", default: false },
1618
3039
  "idempotency-key": { type: "string" },
1619
- timestamp: { type: "string" },
1620
3040
  help: { type: "boolean", short: "h", default: false }
1621
3041
  }
1622
3042
  });
1623
3043
  if (values2.help) {
1624
- ctx.out.log(usage6);
3044
+ ctx.out.log(usage8);
1625
3045
  return;
1626
3046
  }
1627
- const name = positionals[0];
1628
- if (!name) {
3047
+ const template = positionals[0];
3048
+ if (!template) {
1629
3049
  ctx.out.fail(
1630
- "events send requires an event name, e.g. hogsend events send signup --user-id user_123"
3050
+ "emails send requires a template, e.g. hogsend emails send welcome --to a@b.com"
1631
3051
  );
1632
3052
  }
1633
- const email = values2.email;
3053
+ const to = values2.to;
1634
3054
  const userId = values2["user-id"];
1635
- if (!email && !userId) {
1636
- ctx.out.fail("events send requires at least one of --email or --user-id");
3055
+ if (!to && !userId) {
3056
+ ctx.out.fail("emails send requires at least one of --to or --user-id");
1637
3057
  }
1638
- const eventProperties = parseProps4(ctx, values2.props, values2.prop, "prop");
1639
- const contactProperties = parseProps4(
1640
- ctx,
1641
- values2["contact-props"],
1642
- values2["contact-prop"],
1643
- "contact-prop"
1644
- );
1645
- const lists = parseLists2(values2.list, values2.unlist);
1646
- const body = { name };
1647
- if (email) body.email = email;
3058
+ const props = parseProps4(ctx, values2.props, values2.prop);
3059
+ const body = { template };
3060
+ if (to) body.to = to;
1648
3061
  if (userId) body.userId = userId;
1649
- if (eventProperties) body.eventProperties = eventProperties;
1650
- if (contactProperties) body.contactProperties = contactProperties;
1651
- if (lists) body.lists = lists;
3062
+ if (props) body.props = props;
3063
+ if (values2.from) body.from = values2.from;
3064
+ if (values2.subject) body.subject = values2.subject;
3065
+ if (values2["reply-to"]) body.replyTo = values2["reply-to"];
3066
+ if (values2.category) body.category = values2.category;
3067
+ if (values2["skip-preference-check"]) body.skipPreferenceCheck = true;
1652
3068
  if (values2["idempotency-key"]) {
1653
3069
  body.idempotencyKey = values2["idempotency-key"];
1654
3070
  }
1655
- if (values2.timestamp) body.timestamp = values2.timestamp;
1656
3071
  let res;
1657
3072
  try {
1658
3073
  res = await ctx.out.step(
1659
- `Sending event ${name}`,
1660
- () => ctx.dataHttp.post("/v1/events", body)
3074
+ `Sending ${template}`,
3075
+ () => ctx.dataHttp.post("/v1/emails", body)
1661
3076
  );
1662
3077
  } catch (error) {
1663
3078
  if (isHttpError(error)) {
@@ -1669,103 +3084,43 @@ async function runSend3(ctx, argv) {
1669
3084
  ctx.out.json(res);
1670
3085
  return;
1671
3086
  }
1672
- ctx.out.intro(`${color.bgMagenta(color.black(" hogsend "))} events send`);
1673
- const exited = res.exits.filter((e) => e.exited);
3087
+ ctx.out.intro(`${badge4} send`);
1674
3088
  ctx.out.kv(
1675
3089
  {
1676
- event: name,
1677
- stored: res.stored,
1678
- identity: email ?? userId ?? "",
1679
- exits: res.exits.length,
1680
- "journeys exited": exited.length
3090
+ emailSendId: res.emailSendId,
3091
+ template,
3092
+ recipient: to ?? userId ?? "",
3093
+ status: statusColor2(res.status),
3094
+ reason: res.reason ?? ""
1681
3095
  },
1682
- "Event sent"
1683
- );
1684
- if (exited.length > 0) {
1685
- ctx.out.table(
1686
- exited.map((e) => ({ journeyId: e.journeyId, stateId: e.stateId })),
1687
- ["journeyId", "stateId"]
1688
- );
1689
- }
1690
- ctx.out.outro(
1691
- res.stored ? `${color.green("Stored")} ${name}.` : color.dim(`${name} was deduped (not stored).`)
3096
+ "Email send"
1692
3097
  );
3098
+ ctx.out.outro(`${template} \u2192 ${statusColor2(res.status)}.`);
1693
3099
  }
1694
- function parseProps4(ctx, json2, pairs, flagName) {
1695
- const out = {};
1696
- let any = false;
1697
- if (json2 !== void 0) {
1698
- let parsed;
1699
- try {
1700
- parsed = JSON.parse(json2);
1701
- } catch {
1702
- ctx.out.fail(`--${flagName}s must be valid JSON, got: ${json2}`);
1703
- }
1704
- if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
1705
- ctx.out.fail(`--${flagName}s must be a JSON object`);
1706
- }
1707
- Object.assign(out, parsed);
1708
- any = true;
1709
- }
1710
- for (const pair of pairs ?? []) {
1711
- const eq2 = pair.indexOf("=");
1712
- if (eq2 === -1) {
1713
- ctx.out.fail(`--${flagName} must be key=value, got: ${pair}`);
1714
- }
1715
- const key = pair.slice(0, eq2).trim();
1716
- if (key === "") {
1717
- ctx.out.fail(`--${flagName} key cannot be empty, got: ${pair}`);
1718
- }
1719
- out[key] = coerceValue4(pair.slice(eq2 + 1));
1720
- any = true;
1721
- }
1722
- return any ? out : void 0;
1723
- }
1724
- function coerceValue4(raw) {
1725
- try {
1726
- return JSON.parse(raw);
1727
- } catch {
1728
- return raw;
1729
- }
1730
- }
1731
- function parseLists2(subscribe, unsubscribe) {
1732
- const out = {};
1733
- let any = false;
1734
- for (const id of subscribe ?? []) {
1735
- out[id] = true;
1736
- any = true;
1737
- }
1738
- for (const id of unsubscribe ?? []) {
1739
- out[id] = false;
1740
- any = true;
1741
- }
1742
- return any ? out : void 0;
1743
- }
1744
- function parseNumber(raw, name, ctx) {
1745
- if (raw === void 0) return void 0;
1746
- const n = Number(raw);
1747
- if (!Number.isFinite(n)) {
1748
- ctx.out.fail(`--${name} must be a number, got "${raw}"`);
3100
+ async function run8(ctx) {
3101
+ const sub = ctx.argv[0];
3102
+ switch (sub) {
3103
+ case "send":
3104
+ return runSend3(ctx, ctx.argv.slice(1));
3105
+ case void 0:
3106
+ ctx.out.fail(
3107
+ "emails requires a subcommand: send (see hogsend emails --help)"
3108
+ );
3109
+ break;
3110
+ default:
3111
+ ctx.out.fail(`unknown emails subcommand "${sub}" \u2014 expected send`);
1749
3112
  }
1750
- return n;
1751
- }
1752
- function summarizeProps(props) {
1753
- if (!props) return "";
1754
- const keys = Object.keys(props);
1755
- if (keys.length === 0) return "";
1756
- const preview = JSON.stringify(props);
1757
- return preview.length > 60 ? `${preview.slice(0, 57)}...` : preview;
1758
3113
  }
1759
- var eventsCommand = {
1760
- name: "events",
1761
- summary: "Stream a user's event history, or send an event",
1762
- usage: usage6,
1763
- run: run6
3114
+ var emailsCommand = {
3115
+ name: "emails",
3116
+ summary: "Send a transactional email through the data plane",
3117
+ usage: usage8,
3118
+ run: run8
1764
3119
  };
1765
3120
 
1766
3121
  // src/commands/journeys.ts
1767
- import { parseArgs as parseArgs7 } from "util";
1768
- var usage7 = `hogsend journeys <subcommand> [options]
3122
+ import { parseArgs as parseArgs10 } from "util";
3123
+ var usage9 = `hogsend journeys <subcommand> [options]
1769
3124
 
1770
3125
  Inspect and toggle journeys via the admin API (/v1/admin/journeys).
1771
3126
 
@@ -1787,14 +3142,14 @@ Examples:
1787
3142
  hogsend journeys list --enabled true
1788
3143
  hogsend journeys get activation-welcome --json
1789
3144
  hogsend journeys disable churn-prevention`;
1790
- function badge4() {
3145
+ function badge5() {
1791
3146
  return `${color.bgMagenta(color.black(" hogsend "))} journeys`;
1792
3147
  }
1793
3148
  function statusColor3(enabled) {
1794
3149
  return enabled ? color.green("enabled") : color.yellow("disabled");
1795
3150
  }
1796
3151
  async function runList2(ctx) {
1797
- const { values: values2 } = parseArgs7({
3152
+ const { values: values2 } = parseArgs10({
1798
3153
  args: ctx.argv,
1799
3154
  allowPositionals: true,
1800
3155
  options: {
@@ -1805,7 +3160,7 @@ async function runList2(ctx) {
1805
3160
  }
1806
3161
  });
1807
3162
  if (values2.help) {
1808
- ctx.out.log(usage7);
3163
+ ctx.out.log(usage9);
1809
3164
  return;
1810
3165
  }
1811
3166
  if (values2.enabled !== void 0 && !["true", "false"].includes(values2.enabled)) {
@@ -1816,7 +3171,7 @@ async function runList2(ctx) {
1816
3171
  limit: values2.limit,
1817
3172
  offset: values2.offset
1818
3173
  };
1819
- if (!ctx.json) ctx.out.intro(badge4());
3174
+ if (!ctx.json) ctx.out.intro(badge5());
1820
3175
  const data = await ctx.out.step(
1821
3176
  "Fetching journeys",
1822
3177
  () => ctx.http.get("/v1/admin/journeys", query)
@@ -1861,7 +3216,7 @@ async function runGet2(ctx, id) {
1861
3216
  "journeys get requires a journey id, e.g. hogsend journeys get activation-welcome"
1862
3217
  );
1863
3218
  }
1864
- if (!ctx.json) ctx.out.intro(badge4());
3219
+ if (!ctx.json) ctx.out.intro(badge5());
1865
3220
  const data = await ctx.out.step(
1866
3221
  `Fetching journey ${id}`,
1867
3222
  () => ctx.http.get(
@@ -1918,7 +3273,7 @@ async function runToggle(ctx, id, enabled) {
1918
3273
  `journeys ${verb} requires a journey id, e.g. hogsend journeys ${verb} activation-welcome`
1919
3274
  );
1920
3275
  }
1921
- if (!ctx.json) ctx.out.intro(badge4());
3276
+ if (!ctx.json) ctx.out.intro(badge5());
1922
3277
  const data = await ctx.out.step(
1923
3278
  `${enabled ? "Enabling" : "Disabling"} ${id}`,
1924
3279
  () => ctx.http.patch(
@@ -1941,7 +3296,7 @@ async function runToggle(ctx, id, enabled) {
1941
3296
  );
1942
3297
  ctx.out.outro(`${j.id} is now ${statusColor3(j.enabled)}.`);
1943
3298
  }
1944
- async function run7(ctx) {
3299
+ async function run9(ctx) {
1945
3300
  const sub = ctx.argv[0];
1946
3301
  const rest = ctx.argv.slice(1);
1947
3302
  const subCtx = { ...ctx, argv: rest };
@@ -1953,7 +3308,7 @@ async function run7(ctx) {
1953
3308
  case "get": {
1954
3309
  const id = rest.find((a) => !a.startsWith("-"));
1955
3310
  if (rest.includes("--help") || rest.includes("-h")) {
1956
- ctx.out.log(usage7);
3311
+ ctx.out.log(usage9);
1957
3312
  return;
1958
3313
  }
1959
3314
  await runGet2(subCtx, id);
@@ -1961,7 +3316,7 @@ async function run7(ctx) {
1961
3316
  }
1962
3317
  case "enable": {
1963
3318
  if (rest.includes("--help") || rest.includes("-h")) {
1964
- ctx.out.log(usage7);
3319
+ ctx.out.log(usage9);
1965
3320
  return;
1966
3321
  }
1967
3322
  await runToggle(
@@ -1973,7 +3328,7 @@ async function run7(ctx) {
1973
3328
  }
1974
3329
  case "disable": {
1975
3330
  if (rest.includes("--help") || rest.includes("-h")) {
1976
- ctx.out.log(usage7);
3331
+ ctx.out.log(usage9);
1977
3332
  return;
1978
3333
  }
1979
3334
  await runToggle(
@@ -2007,14 +3362,14 @@ async function run7(ctx) {
2007
3362
  var journeysCommand = {
2008
3363
  name: "journeys",
2009
3364
  summary: "List, inspect, enable, and disable journeys",
2010
- usage: usage7,
2011
- run: run7
3365
+ usage: usage9,
3366
+ run: run9
2012
3367
  };
2013
3368
 
2014
- // src/commands/patch.ts
2015
- import { spawnSync } from "child_process";
2016
- import { parseArgs as parseArgs8 } from "util";
2017
- var usage8 = `hogsend patch <package> [--cwd <dir>]
3369
+ // src/commands/patch.ts
3370
+ import { spawnSync as spawnSync3 } from "child_process";
3371
+ import { parseArgs as parseArgs11 } from "util";
3372
+ var usage10 = `hogsend patch <package> [--cwd <dir>]
2018
3373
 
2019
3374
  Thin wrapper over pnpm's native patch flow. Runs \`pnpm patch <package>\`, which
2020
3375
  extracts the package into a temp dir and prints the path to edit. After editing,
@@ -2025,8 +3380,8 @@ This does NOT replace scripts/patch-check.sh (the patch re-apply contract).
2025
3380
  Options:
2026
3381
  --cwd <dir> Project root to run pnpm in (defaults to current directory).
2027
3382
  -h, --help Show this help.`;
2028
- async function run8(ctx) {
2029
- const { values: values2, positionals } = parseArgs8({
3383
+ async function run10(ctx) {
3384
+ const { values: values2, positionals } = parseArgs11({
2030
3385
  args: ctx.argv,
2031
3386
  allowPositionals: true,
2032
3387
  options: {
@@ -2035,7 +3390,7 @@ async function run8(ctx) {
2035
3390
  }
2036
3391
  });
2037
3392
  if (values2.help) {
2038
- ctx.out.log(usage8);
3393
+ ctx.out.log(usage10);
2039
3394
  return;
2040
3395
  }
2041
3396
  const pkg = positionals[0];
@@ -2045,7 +3400,7 @@ async function run8(ctx) {
2045
3400
  );
2046
3401
  }
2047
3402
  const cwd = values2.cwd ?? process.cwd();
2048
- const result = spawnSync("pnpm", ["patch", pkg], {
3403
+ const result = spawnSync3("pnpm", ["patch", pkg], {
2049
3404
  cwd,
2050
3405
  stdio: ctx.json ? "ignore" : "inherit"
2051
3406
  });
@@ -2075,30 +3430,16 @@ async function run8(ctx) {
2075
3430
  var patchCommand = {
2076
3431
  name: "patch",
2077
3432
  summary: "Patch a package via pnpm's native patch flow",
2078
- usage: usage8,
2079
- run: run8
3433
+ usage: usage10,
3434
+ run: run10
2080
3435
  };
2081
3436
 
2082
3437
  // src/commands/setup.ts
2083
- import { spawnSync as spawnSync2 } from "child_process";
2084
- import { randomBytes } from "crypto";
2085
- import { copyFileSync, existsSync as existsSync4, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
2086
- import { join as join4 } from "path";
2087
- import { parseArgs as parseArgs9 } from "util";
2088
- import { confirm } from "@clack/prompts";
2089
-
2090
- // src/lib/prompt.ts
2091
- import { cancel as cancel2, isCancel } from "@clack/prompts";
2092
- function bail(value) {
2093
- if (isCancel(value)) {
2094
- cancel2("Cancelled.");
2095
- process.exit(0);
2096
- }
2097
- return value;
2098
- }
2099
-
2100
- // src/commands/setup.ts
2101
- var usage9 = `hogsend setup [--cwd <dir>] [--yes] [--json]
3438
+ import { existsSync as existsSync7 } from "fs";
3439
+ import { join as join7 } from "path";
3440
+ import { parseArgs as parseArgs12 } from "util";
3441
+ import { confirm as confirm2 } from "@clack/prompts";
3442
+ var usage11 = `hogsend setup [--cwd <dir>] [--yes] [--json]
2102
3443
 
2103
3444
  Interactive local onboarding for a scaffolded Hogsend app. Mirrors the
2104
3445
  create-hogsend "next steps":
@@ -2115,102 +3456,8 @@ Options:
2115
3456
  -h, --help Show this help.
2116
3457
 
2117
3458
  Run ${color.cyan("hogsend doctor")} afterwards to verify the instance is healthy.`;
2118
- function generateSecret() {
2119
- return randomBytes(32).toString("hex");
2120
- }
2121
- var SECRET_KEY = "BETTER_AUTH_SECRET";
2122
- var PLACEHOLDER_PREFIX = "change-me";
2123
- function ensureEnv(cwd) {
2124
- const envPath = join4(cwd, ".env");
2125
- const examplePath = join4(cwd, ".env.example");
2126
- let copied;
2127
- if (existsSync4(envPath)) {
2128
- copied = {
2129
- step: "env",
2130
- status: "skipped",
2131
- detail: ".env already exists"
2132
- };
2133
- } else if (existsSync4(examplePath)) {
2134
- copyFileSync(examplePath, envPath);
2135
- copied = {
2136
- step: "env",
2137
- status: "ok",
2138
- detail: "copied .env.example -> .env"
2139
- };
2140
- } else {
2141
- copied = {
2142
- step: "env",
2143
- status: "failed",
2144
- detail: "no .env and no .env.example to copy from"
2145
- };
2146
- return {
2147
- copied,
2148
- secret: {
2149
- step: "secret",
2150
- status: "skipped",
2151
- detail: "skipped \u2014 no .env"
2152
- }
2153
- };
2154
- }
2155
- let raw;
2156
- try {
2157
- raw = readFileSync2(envPath, "utf8");
2158
- } catch (err) {
2159
- return {
2160
- copied,
2161
- secret: {
2162
- step: "secret",
2163
- status: "failed",
2164
- detail: `could not read .env: ${err instanceof Error ? err.message : String(err)}`
2165
- }
2166
- };
2167
- }
2168
- const lines = raw.split(/\r?\n/);
2169
- const idx = lines.findIndex(
2170
- (l) => l.replace(/^export\s+/, "").trimStart().startsWith(`${SECRET_KEY}=`)
2171
- );
2172
- const existingLine = idx === -1 ? void 0 : lines[idx];
2173
- const current = existingLine === void 0 ? void 0 : existingLine.slice(existingLine.indexOf("=") + 1).trim();
2174
- const isPlaceholder = current === void 0 || current === "" || current.startsWith(PLACEHOLDER_PREFIX);
2175
- if (!isPlaceholder) {
2176
- return {
2177
- copied,
2178
- secret: {
2179
- step: "secret",
2180
- status: "skipped",
2181
- detail: `${SECRET_KEY} already set`
2182
- }
2183
- };
2184
- }
2185
- const secret = generateSecret();
2186
- const newLine = `${SECRET_KEY}=${secret}`;
2187
- if (idx === -1) {
2188
- if (raw.length > 0 && !raw.endsWith("\n")) lines.push("");
2189
- lines.push(newLine);
2190
- } else {
2191
- lines[idx] = newLine;
2192
- }
2193
- writeFileSync2(envPath, lines.join("\n"));
2194
- return {
2195
- copied,
2196
- secret: {
2197
- step: "secret",
2198
- status: "ok",
2199
- detail: `generated ${SECRET_KEY} (64-char hex)`
2200
- }
2201
- };
2202
- }
2203
- function runCmd(cmd, args, cwd, json2) {
2204
- const result = spawnSync2(cmd, args, {
2205
- cwd,
2206
- // In json mode stay silent (we report structured status); otherwise stream
2207
- // so the user sees docker / migration output inline.
2208
- stdio: json2 ? "ignore" : "inherit"
2209
- });
2210
- return { status: result.status, ok: result.status === 0 };
2211
- }
2212
- async function run9(ctx) {
2213
- const { values: values2 } = parseArgs9({
3459
+ async function run11(ctx) {
3460
+ const { values: values2 } = parseArgs12({
2214
3461
  args: ctx.argv,
2215
3462
  allowPositionals: true,
2216
3463
  options: {
@@ -2220,16 +3467,16 @@ async function run9(ctx) {
2220
3467
  }
2221
3468
  });
2222
3469
  if (values2.help) {
2223
- ctx.out.log(usage9);
3470
+ ctx.out.log(usage11);
2224
3471
  return;
2225
3472
  }
2226
3473
  const cwd = values2.cwd ?? process.cwd();
2227
- if (!existsSync4(join4(cwd, "package.json"))) {
3474
+ if (!existsSync7(join7(cwd, "package.json"))) {
2228
3475
  ctx.out.fail(
2229
3476
  `no package.json in ${cwd} \u2014 run setup from a scaffolded Hogsend app (or pass --cwd).`
2230
3477
  );
2231
3478
  }
2232
- const hasCompose = existsSync4(join4(cwd, "docker-compose.yml")) || existsSync4(join4(cwd, "docker-compose.yaml")) || existsSync4(join4(cwd, "compose.yml")) || existsSync4(join4(cwd, "compose.yaml"));
3479
+ const hasCompose = hasComposeFile(cwd);
2233
3480
  const skipConfirm = ctx.json || values2.yes;
2234
3481
  if (!ctx.json) {
2235
3482
  ctx.out.intro(
@@ -2238,7 +3485,7 @@ async function run9(ctx) {
2238
3485
  }
2239
3486
  if (ctx.out.interactive && !skipConfirm) {
2240
3487
  const proceed = bail(
2241
- await confirm({
3488
+ await confirm2({
2242
3489
  message: `Set up local infra in ${color.cyan(cwd)}? (docker compose up, .env, db:migrate)`
2243
3490
  })
2244
3491
  );
@@ -2249,15 +3496,23 @@ async function run9(ctx) {
2249
3496
  }
2250
3497
  const results = [];
2251
3498
  if (hasCompose) {
2252
- const docker = await ctx.out.step(
2253
- "Starting infra (docker compose up -d)",
2254
- async () => runCmd("docker", ["compose", "up", "-d"], cwd, ctx.json)
3499
+ const infra = await ctx.out.step(
3500
+ "Checking infra",
3501
+ async () => detectRunningInfra(cwd)
2255
3502
  );
2256
- results.push({
2257
- step: "docker",
2258
- status: docker.ok ? "ok" : "failed",
2259
- detail: docker.ok ? "Postgres + Redis + Hatchet-Lite up" : `docker compose exited with code ${docker.status ?? "?"}`
2260
- });
3503
+ if (infra.postgres && infra.redis && infra.hatchet) {
3504
+ results.push({
3505
+ step: "docker",
3506
+ status: "skipped",
3507
+ detail: "infra already running"
3508
+ });
3509
+ } else {
3510
+ const docker = await ctx.out.step(
3511
+ "Starting infra (docker compose up -d)",
3512
+ async () => dockerComposeUp(cwd, { quiet: ctx.json })
3513
+ );
3514
+ results.push(docker);
3515
+ }
2261
3516
  } else {
2262
3517
  results.push({
2263
3518
  step: "docker",
@@ -2265,10 +3520,10 @@ async function run9(ctx) {
2265
3520
  detail: "no docker-compose file found"
2266
3521
  });
2267
3522
  }
2268
- const env = await ctx.out.step(
2269
- "Preparing .env + auth secret",
2270
- async () => ensureEnv(cwd)
2271
- );
3523
+ const env = await ctx.out.step("Preparing .env + auth secret", async () => ({
3524
+ copied: ensureEnvFile(cwd),
3525
+ secret: ensureAuthSecret(cwd)
3526
+ }));
2272
3527
  results.push(env.copied, env.secret);
2273
3528
  const dockerFailed = results.some(
2274
3529
  (r) => r.step === "docker" && r.status === "failed"
@@ -2282,13 +3537,9 @@ async function run9(ctx) {
2282
3537
  } else {
2283
3538
  const migrate = await ctx.out.step(
2284
3539
  "Running migrations (pnpm db:migrate)",
2285
- async () => runCmd("pnpm", ["db:migrate"], cwd, ctx.json)
3540
+ async () => runMigrations(cwd, { quiet: ctx.json })
2286
3541
  );
2287
- results.push({
2288
- step: "migrate",
2289
- status: migrate.ok ? "ok" : "failed",
2290
- detail: migrate.ok ? "engine + client migrations applied" : `pnpm db:migrate exited with code ${migrate.status ?? "?"}`
2291
- });
3542
+ results.push(migrate);
2292
3543
  }
2293
3544
  const failed = results.filter((r) => r.status === "failed");
2294
3545
  const ok = failed.length === 0;
@@ -2331,16 +3582,16 @@ async function run9(ctx) {
2331
3582
  var setupCommand = {
2332
3583
  name: "setup",
2333
3584
  summary: "Local onboarding: docker compose up, gen secret, db:migrate",
2334
- usage: usage9,
2335
- run: run9
3585
+ usage: usage11,
3586
+ run: run11
2336
3587
  };
2337
3588
 
2338
3589
  // src/commands/skills.ts
2339
- import { existsSync as existsSync5 } from "fs";
2340
- import { join as join5 } from "path";
2341
- import { parseArgs as parseArgs10 } from "util";
3590
+ import { existsSync as existsSync8 } from "fs";
3591
+ import { join as join8 } from "path";
3592
+ import { parseArgs as parseArgs13 } from "util";
2342
3593
  import { multiselect } from "@clack/prompts";
2343
- var usage10 = `hogsend skills <subcommand> [options]
3594
+ var usage12 = `hogsend skills <subcommand> [options]
2344
3595
 
2345
3596
  Manage the Claude Code skills bundled with @hogsend/cli. Bundled skills teach
2346
3597
  agents how to drive the hogsend CLI; \`add\` copies them into your project's
@@ -2400,8 +3651,8 @@ function runList3(ctx) {
2400
3651
  `Install with ${color.cyan("hogsend skills add <name>")} (or ${color.cyan("hogsend skills add --all")}). Refresh after an engine upgrade with ${color.cyan("--force")}.`
2401
3652
  );
2402
3653
  }
2403
- async function runAdd(ctx, argv) {
2404
- const { values: values2, positionals } = parseArgs10({
3654
+ async function runAdd2(ctx, argv) {
3655
+ const { values: values2, positionals } = parseArgs13({
2405
3656
  args: argv,
2406
3657
  allowPositionals: true,
2407
3658
  options: {
@@ -2411,7 +3662,7 @@ async function runAdd(ctx, argv) {
2411
3662
  }
2412
3663
  });
2413
3664
  if (values2.help) {
2414
- ctx.out.log(usage10);
3665
+ ctx.out.log(usage12);
2415
3666
  return;
2416
3667
  }
2417
3668
  const cwd = process.cwd();
@@ -2452,7 +3703,7 @@ async function runAdd(ctx, argv) {
2452
3703
  (name) => copySkill(name, cwd, force)
2453
3704
  );
2454
3705
  if (results.some((r) => r.installed)) {
2455
- const installedNames = listBundledSkills(cwd).filter((s) => existsSync5(join5(installDir(cwd), s.name))).map((s) => s.name);
3706
+ const installedNames = listBundledSkills(cwd).filter((s) => existsSync8(join8(installDir(cwd), s.name))).map((s) => s.name);
2456
3707
  writeSkillsStamp(cwd, installedNames);
2457
3708
  }
2458
3709
  if (ctx.json) {
@@ -2479,19 +3730,19 @@ async function runAdd(ctx, argv) {
2479
3730
  `Installed ${installedCount} skill${installedCount === 1 ? "" : "s"}` + (skippedCount > 0 ? `, skipped ${skippedCount}.` : ".")
2480
3731
  );
2481
3732
  }
2482
- async function run10(ctx) {
3733
+ async function run12(ctx) {
2483
3734
  const sub = ctx.argv[0];
2484
3735
  switch (sub) {
2485
3736
  case "list":
2486
3737
  runList3(ctx);
2487
3738
  return;
2488
3739
  case "add":
2489
- await runAdd(ctx, ctx.argv.slice(1));
3740
+ await runAdd2(ctx, ctx.argv.slice(1));
2490
3741
  return;
2491
3742
  case void 0:
2492
3743
  case "-h":
2493
3744
  case "--help":
2494
- ctx.out.log(usage10);
3745
+ ctx.out.log(usage12);
2495
3746
  return;
2496
3747
  default:
2497
3748
  ctx.out.fail(
@@ -2502,13 +3753,13 @@ async function run10(ctx) {
2502
3753
  var skillsCommand = {
2503
3754
  name: "skills",
2504
3755
  summary: "List + install bundled Claude Code skills into .claude/skills",
2505
- usage: usage10,
2506
- run: run10
3756
+ usage: usage12,
3757
+ run: run12
2507
3758
  };
2508
3759
 
2509
3760
  // src/commands/stats.ts
2510
- import { parseArgs as parseArgs11 } from "util";
2511
- var usage11 = `hogsend stats [--json]
3761
+ import { parseArgs as parseArgs14 } from "util";
3762
+ var usage13 = `hogsend stats [--json]
2512
3763
 
2513
3764
  Show system-wide overview metrics from a running Hogsend instance.
2514
3765
  Wraps GET /v1/admin/metrics/overview.
@@ -2530,8 +3781,8 @@ Options:
2530
3781
  function pct(rate) {
2531
3782
  return `${(rate * 100).toFixed(2)}%`;
2532
3783
  }
2533
- async function run11(ctx) {
2534
- const { values: values2 } = parseArgs11({
3784
+ async function run13(ctx) {
3785
+ const { values: values2 } = parseArgs14({
2535
3786
  args: ctx.argv,
2536
3787
  allowPositionals: true,
2537
3788
  options: {
@@ -2539,7 +3790,7 @@ async function run11(ctx) {
2539
3790
  }
2540
3791
  });
2541
3792
  if (values2.help) {
2542
- ctx.out.log(usage11);
3793
+ ctx.out.log(usage13);
2543
3794
  return;
2544
3795
  }
2545
3796
  const metrics = await ctx.out.step(
@@ -2568,20 +3819,20 @@ async function run11(ctx) {
2568
3819
  var statsCommand = {
2569
3820
  name: "stats",
2570
3821
  summary: "Show system-wide overview metrics",
2571
- usage: usage11,
2572
- run: run11
3822
+ usage: usage13,
3823
+ run: run13
2573
3824
  };
2574
3825
 
2575
3826
  // src/commands/studio.ts
2576
- import { spawn } from "child_process";
2577
- import { createReadStream, existsSync as existsSync6, readFileSync as readFileSync3, statSync as statSync2 } from "fs";
3827
+ import { spawn as spawn2 } from "child_process";
3828
+ import { createReadStream, existsSync as existsSync9, readFileSync as readFileSync5, statSync as statSync2 } from "fs";
2578
3829
  import { createServer } from "http";
2579
- import { extname, join as join6, normalize, resolve, sep as sep3 } from "path";
3830
+ import { extname, join as join9, normalize, resolve as resolve2, sep as sep3 } from "path";
2580
3831
  import { fileURLToPath as fileURLToPath2 } from "url";
2581
- import { parseArgs as parseArgs13 } from "util";
3832
+ import { parseArgs as parseArgs16 } from "util";
2582
3833
 
2583
3834
  // src/commands/studio-admin.ts
2584
- import { parseArgs as parseArgs12 } from "util";
3835
+ import { parseArgs as parseArgs15 } from "util";
2585
3836
  import { password as passwordPrompt, text as text2 } from "@clack/prompts";
2586
3837
 
2587
3838
  // ../../node_modules/.pnpm/postgres@3.4.9/node_modules/postgres/src/index.js
@@ -2595,9 +3846,9 @@ var originError = /* @__PURE__ */ Symbol("OriginError");
2595
3846
  var CLOSE = {};
2596
3847
  var Query = class extends Promise {
2597
3848
  constructor(strings, args, handler, canceller, options = {}) {
2598
- let resolve2, reject;
3849
+ let resolve3, reject;
2599
3850
  super((a, b2) => {
2600
- resolve2 = a;
3851
+ resolve3 = a;
2601
3852
  reject = b2;
2602
3853
  });
2603
3854
  this.tagged = Array.isArray(strings.raw);
@@ -2608,7 +3859,7 @@ var Query = class extends Promise {
2608
3859
  this.options = options;
2609
3860
  this.state = null;
2610
3861
  this.statement = null;
2611
- this.resolve = (x) => (this.active = false, resolve2(x));
3862
+ this.resolve = (x) => (this.active = false, resolve3(x));
2612
3863
  this.reject = (x) => (this.active = false, reject(x));
2613
3864
  this.active = false;
2614
3865
  this.cancelled = null;
@@ -2656,12 +3907,12 @@ var Query = class extends Promise {
2656
3907
  if (this.executed && !this.active)
2657
3908
  return { done: true };
2658
3909
  prev && prev();
2659
- const promise = new Promise((resolve2, reject) => {
3910
+ const promise = new Promise((resolve3, reject) => {
2660
3911
  this.cursorFn = (value) => {
2661
- resolve2({ value, done: false });
3912
+ resolve3({ value, done: false });
2662
3913
  return new Promise((r) => prev = r);
2663
3914
  };
2664
- this.resolve = () => (this.active = false, resolve2({ done: true }));
3915
+ this.resolve = () => (this.active = false, resolve3({ done: true }));
2665
3916
  this.reject = (x) => (this.active = false, reject(x));
2666
3917
  });
2667
3918
  this.execute();
@@ -3288,12 +4539,12 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose
3288
4539
  x.on("drain", drain);
3289
4540
  return x;
3290
4541
  }
3291
- async function cancel3({ pid, secret }, resolve2, reject) {
4542
+ async function cancel3({ pid, secret }, resolve3, reject) {
3292
4543
  try {
3293
4544
  cancelMessage = bytes_default().i32(16).i32(80877102).i32(pid).i32(secret).end(16);
3294
- await connect();
4545
+ await connect2();
3295
4546
  socket.once("error", reject);
3296
- socket.once("close", resolve2);
4547
+ socket.once("close", resolve3);
3297
4548
  } catch (error2) {
3298
4549
  reject(error2);
3299
4550
  }
@@ -3424,7 +4675,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose
3424
4675
  incomings = null;
3425
4676
  }
3426
4677
  }
3427
- async function connect() {
4678
+ async function connect2() {
3428
4679
  terminated = false;
3429
4680
  backendParameters = {};
3430
4681
  socket || (socket = await createSocket());
@@ -3443,7 +4694,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose
3443
4694
  hostIndex = (hostIndex + 1) % port.length;
3444
4695
  }
3445
4696
  function reconnect() {
3446
- setTimeout(connect, closedTime ? Math.max(0, closedTime + delay - performance.now()) : 0);
4697
+ setTimeout(connect2, closedTime ? Math.max(0, closedTime + delay - performance.now()) : 0);
3447
4698
  }
3448
4699
  function connected() {
3449
4700
  try {
@@ -4240,7 +5491,7 @@ function parseEvent(x) {
4240
5491
  // ../../node_modules/.pnpm/postgres@3.4.9/node_modules/postgres/src/large.js
4241
5492
  import Stream2 from "stream";
4242
5493
  function largeObject(sql2, oid, mode = 131072 | 262144) {
4243
- return new Promise(async (resolve2, reject) => {
5494
+ return new Promise(async (resolve3, reject) => {
4244
5495
  await sql2.begin(async (sql3) => {
4245
5496
  let finish;
4246
5497
  !oid && ([{ oid }] = await sql3`select lo_creat(-1) as oid`);
@@ -4266,7 +5517,7 @@ function largeObject(sql2, oid, mode = 131072 | 262144) {
4266
5517
  ) seek
4267
5518
  `
4268
5519
  };
4269
- resolve2(lo);
5520
+ resolve3(lo);
4270
5521
  return new Promise(async (r) => finish = r);
4271
5522
  async function readable({
4272
5523
  highWaterMark = 2048 * 8,
@@ -4440,10 +5691,10 @@ function Postgres(a, b2) {
4440
5691
  }
4441
5692
  async function reserve() {
4442
5693
  const queue = queue_default();
4443
- const c = open.length ? open.shift() : await new Promise((resolve2, reject) => {
4444
- const query = { reserve: resolve2, reject };
5694
+ const c = open.length ? open.shift() : await new Promise((resolve3, reject) => {
5695
+ const query = { reserve: resolve3, reject };
4445
5696
  queries.push(query);
4446
- closed.length && connect(closed.shift(), query);
5697
+ closed.length && connect2(closed.shift(), query);
4447
5698
  });
4448
5699
  move(c, reserved);
4449
5700
  c.reserved = () => queue.length ? c.execute(queue.shift()) : move(c, reserved);
@@ -4478,9 +5729,9 @@ function Postgres(a, b2) {
4478
5729
  let uncaughtError, result;
4479
5730
  name && await sql3`savepoint ${sql3(name)}`;
4480
5731
  try {
4481
- result = await new Promise((resolve2, reject) => {
5732
+ result = await new Promise((resolve3, reject) => {
4482
5733
  const x = fn2(sql3);
4483
- Promise.resolve(Array.isArray(x) ? Promise.all(x) : x).then(resolve2, reject);
5734
+ Promise.resolve(Array.isArray(x) ? Promise.all(x) : x).then(resolve3, reject);
4484
5735
  });
4485
5736
  if (uncaughtError)
4486
5737
  throw uncaughtError;
@@ -4530,15 +5781,15 @@ function Postgres(a, b2) {
4530
5781
  if (open.length)
4531
5782
  return go(open.shift(), query);
4532
5783
  if (closed.length)
4533
- return connect(closed.shift(), query);
5784
+ return connect2(closed.shift(), query);
4534
5785
  busy.length ? go(busy.shift(), query) : queries.push(query);
4535
5786
  }
4536
5787
  function go(c, query) {
4537
5788
  return c.execute(query) ? move(c, busy) : move(c, full);
4538
5789
  }
4539
5790
  function cancel3(query) {
4540
- return new Promise((resolve2, reject) => {
4541
- query.state ? query.active ? connection_default(options).cancel(query.state, resolve2, reject) : query.cancelled = { resolve: resolve2, reject } : (queries.remove(query), query.cancelled = true, query.reject(Errors.generic("57014", "canceling statement due to user request")), resolve2());
5791
+ return new Promise((resolve3, reject) => {
5792
+ query.state ? query.active ? connection_default(options).cancel(query.state, resolve3, reject) : query.cancelled = { resolve: resolve3, reject } : (queries.remove(query), query.cancelled = true, query.reject(Errors.generic("57014", "canceling statement due to user request")), resolve3());
4542
5793
  });
4543
5794
  }
4544
5795
  async function end({ timeout = null } = {}) {
@@ -4557,13 +5808,13 @@ function Postgres(a, b2) {
4557
5808
  async function close() {
4558
5809
  await Promise.all(connections.map((c) => c.end()));
4559
5810
  }
4560
- async function destroy(resolve2) {
5811
+ async function destroy(resolve3) {
4561
5812
  await Promise.all(connections.map((c) => c.terminate()));
4562
5813
  while (queries.length)
4563
5814
  queries.shift().reject(Errors.connection("CONNECTION_DESTROYED", options));
4564
- resolve2();
5815
+ resolve3();
4565
5816
  }
4566
- function connect(c, query) {
5817
+ function connect2(c, query) {
4567
5818
  move(c, connecting);
4568
5819
  c.connect(query);
4569
5820
  return c;
@@ -4588,7 +5839,7 @@ function Postgres(a, b2) {
4588
5839
  c.reserved = null;
4589
5840
  c.onclose && (c.onclose(e), c.onclose = null);
4590
5841
  options.onclose && options.onclose(c.id);
4591
- queries.length && connect(c, queries.shift());
5842
+ queries.length && connect2(c, queries.shift());
4592
5843
  }
4593
5844
  }
4594
5845
  function parseOptions(a, b2) {
@@ -5818,7 +7069,7 @@ function sql(strings, ...params) {
5818
7069
  return new SQL([new StringChunk(str)]);
5819
7070
  }
5820
7071
  sql2.raw = raw;
5821
- function join9(chunks, separator) {
7072
+ function join11(chunks, separator) {
5822
7073
  const result = [];
5823
7074
  for (const [i, chunk] of chunks.entries()) {
5824
7075
  if (i > 0 && separator !== void 0) {
@@ -5828,7 +7079,7 @@ function sql(strings, ...params) {
5828
7079
  }
5829
7080
  return new SQL(result);
5830
7081
  }
5831
- sql2.join = join9;
7082
+ sql2.join = join11;
5832
7083
  function identifier(value) {
5833
7084
  return new Name(value);
5834
7085
  }
@@ -9478,7 +10729,7 @@ var PgSelectQueryBuilderBase = class extends TypedQueryBuilder {
9478
10729
  const baseTableName = this.tableName;
9479
10730
  const tableName = getTableLikeName(table);
9480
10731
  for (const item of extractUsedTable(table)) this.usedTables.add(item);
9481
- if (typeof tableName === "string" && this.config.joins?.some((join9) => join9.alias === tableName)) {
10732
+ if (typeof tableName === "string" && this.config.joins?.some((join11) => join11.alias === tableName)) {
9482
10733
  throw new Error(`Alias "${tableName}" is already used in this query`);
9483
10734
  }
9484
10735
  if (!this.isPartialSelect) {
@@ -10699,7 +11950,7 @@ var PgUpdateBase = class extends QueryPromise {
10699
11950
  createJoin(joinType) {
10700
11951
  return (table, on) => {
10701
11952
  const tableName = getTableLikeName(table);
10702
- if (typeof tableName === "string" && this.config.joins.some((join9) => join9.alias === tableName)) {
11953
+ if (typeof tableName === "string" && this.config.joins.some((join11) => join11.alias === tableName)) {
10703
11954
  throw new Error(`Alias "${tableName}" is already used in this query`);
10704
11955
  }
10705
11956
  if (typeof on === "function") {
@@ -10795,10 +12046,10 @@ var PgUpdateBase = class extends QueryPromise {
10795
12046
  const fromFields = this.getTableLikeFields(this.config.from);
10796
12047
  fields[tableName] = fromFields;
10797
12048
  }
10798
- for (const join9 of this.config.joins) {
10799
- const tableName2 = getTableLikeName(join9.table);
10800
- if (typeof tableName2 === "string" && !is(join9.table, SQL)) {
10801
- const fromFields = this.getTableLikeFields(join9.table);
12049
+ for (const join11 of this.config.joins) {
12050
+ const tableName2 = getTableLikeName(join11.table);
12051
+ if (typeof tableName2 === "string" && !is(join11.table, SQL)) {
12052
+ const fromFields = this.getTableLikeFields(join11.table);
10802
12053
  fields[tableName2] = fromFields;
10803
12054
  }
10804
12055
  }
@@ -12350,6 +13601,12 @@ var emailSends = pgTable(
12350
13601
  // mirrors the user_events idempotency pattern. Nullable: journey/system sends
12351
13602
  // don't set it.
12352
13603
  idempotencyKey: text("idempotency_key"),
13604
+ // Free-form per-send annotations. Set ONLY by test-mode redirected sends
13605
+ // today — `{ testMode: true, originalTo: <real recipient> }` — so Studio can
13606
+ // flag a TEST row and show who the mail was REALLY for. Nullable: normal
13607
+ // (live) sends leave it unset. jsonb (mirrors alert-history.payload) leaves
13608
+ // room for future markers without another migration.
13609
+ metadata: jsonb("metadata").$type(),
12353
13610
  ...timestamps
12354
13611
  },
12355
13612
  (table) => [
@@ -12946,7 +14203,7 @@ Examples:
12946
14203
  Security: passwords are written ONLY via better-auth (scrypt) \u2014 never raw SQL,
12947
14204
  never plaintext at rest, never logged. Prefer the masked prompt over --password.`;
12948
14205
  function parseAdminFlags(argv) {
12949
- const { values: values2 } = parseArgs12({
14206
+ const { values: values2 } = parseArgs15({
12950
14207
  args: argv,
12951
14208
  allowPositionals: true,
12952
14209
  strict: false,
@@ -13147,7 +14404,7 @@ async function runStudioAdmin(ctx, argv) {
13147
14404
  }
13148
14405
 
13149
14406
  // src/commands/studio.ts
13150
- var usage12 = `hogsend studio [options]
14407
+ var usage14 = `hogsend studio [options]
13151
14408
 
13152
14409
  Subcommands:
13153
14410
  admin create | reset | list Shell-gated Studio admin recovery (DB + secret).
@@ -13179,16 +14436,16 @@ Examples:
13179
14436
  function resolveStudioDist(distFlag) {
13180
14437
  const candidates = [];
13181
14438
  if (distFlag && distFlag.length > 0) {
13182
- candidates.push(resolve(process.cwd(), distFlag));
14439
+ candidates.push(resolve2(process.cwd(), distFlag));
13183
14440
  }
13184
14441
  candidates.push(fileURLToPath2(new URL("../studio", import.meta.url)));
13185
14442
  candidates.push(
13186
14443
  fileURLToPath2(new URL("../../studio/dist", import.meta.url)),
13187
14444
  fileURLToPath2(new URL("../../../studio/dist", import.meta.url))
13188
14445
  );
13189
- candidates.push(resolve(process.cwd(), "packages/studio/dist"));
14446
+ candidates.push(resolve2(process.cwd(), "packages/studio/dist"));
13190
14447
  for (const dir of candidates) {
13191
- if (existsSync6(join6(dir, "index.html"))) {
14448
+ if (existsSync9(join9(dir, "index.html"))) {
13192
14449
  return dir;
13193
14450
  }
13194
14451
  }
@@ -13215,7 +14472,7 @@ function mimeFor(path) {
13215
14472
  return MIME[extname(path).toLowerCase()] ?? "application/octet-stream";
13216
14473
  }
13217
14474
  function indexHtml(distPath, baseUrl) {
13218
- const raw = readFileSync3(join6(distPath, "index.html"), "utf8");
14475
+ const raw = readFileSync5(join9(distPath, "index.html"), "utf8");
13219
14476
  if (!baseUrl) return raw;
13220
14477
  const inject = `<script>window.__HOGSEND_STUDIO__=${JSON.stringify({
13221
14478
  baseUrl
@@ -13230,19 +14487,19 @@ function openBrowser(url) {
13230
14487
  const cmd = platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open";
13231
14488
  const args = platform === "win32" ? ["/c", "start", "", url] : [url];
13232
14489
  try {
13233
- const child = spawn(cmd, args, { stdio: "ignore", detached: true });
14490
+ const child = spawn2(cmd, args, { stdio: "ignore", detached: true });
13234
14491
  child.on("error", () => {
13235
14492
  });
13236
14493
  child.unref();
13237
14494
  } catch {
13238
14495
  }
13239
14496
  }
13240
- async function run12(ctx) {
14497
+ async function run14(ctx) {
13241
14498
  if (ctx.argv[0] === "admin") {
13242
14499
  await runStudioAdmin(ctx, ctx.argv.slice(1));
13243
14500
  return;
13244
14501
  }
13245
- const { values: values2, positionals } = parseArgs13({
14502
+ const { values: values2, positionals } = parseArgs16({
13246
14503
  args: ctx.argv,
13247
14504
  allowPositionals: true,
13248
14505
  strict: false,
@@ -13255,7 +14512,7 @@ async function run12(ctx) {
13255
14512
  }
13256
14513
  });
13257
14514
  if (values2.help) {
13258
- ctx.out.log(usage12);
14515
+ ctx.out.log(usage14);
13259
14516
  return;
13260
14517
  }
13261
14518
  const port = Number(values2.port ?? "3333");
@@ -13281,13 +14538,13 @@ async function run12(ctx) {
13281
14538
  res.end(index2);
13282
14539
  return;
13283
14540
  }
13284
- const target = normalize(join6(distPath, rel));
14541
+ const target = normalize(join9(distPath, rel));
13285
14542
  if (target !== distPath && !target.startsWith(distPath + sep3)) {
13286
14543
  res.writeHead(403);
13287
14544
  res.end("Forbidden");
13288
14545
  return;
13289
14546
  }
13290
- if (existsSync6(target) && statSync2(target).isFile()) {
14547
+ if (existsSync9(target) && statSync2(target).isFile()) {
13291
14548
  res.writeHead(200, { "content-type": mimeFor(target) });
13292
14549
  createReadStream(target).pipe(res);
13293
14550
  return;
@@ -13341,17 +14598,17 @@ async function run12(ctx) {
13341
14598
  var studioCommand = {
13342
14599
  name: "studio",
13343
14600
  summary: "Serve the bundled Hogsend Studio admin SPA locally",
13344
- usage: usage12,
13345
- run: run12
14601
+ usage: usage14,
14602
+ run: run14
13346
14603
  };
13347
14604
 
13348
14605
  // src/commands/upgrade.ts
13349
- import { spawnSync as spawnSync3 } from "child_process";
13350
- import { existsSync as existsSync7, readFileSync as readFileSync4 } from "fs";
13351
- import { join as join7 } from "path";
13352
- import { parseArgs as parseArgs14 } from "util";
13353
- import { confirm as confirm2 } from "@clack/prompts";
13354
- var usage13 = `hogsend upgrade [--cwd <dir>] [--pm <pnpm|npm|yarn|bun>] [options]
14606
+ import { spawnSync as spawnSync4 } from "child_process";
14607
+ import { existsSync as existsSync10, readFileSync as readFileSync6 } from "fs";
14608
+ import { join as join10 } from "path";
14609
+ import { parseArgs as parseArgs17 } from "util";
14610
+ import { confirm as confirm3 } from "@clack/prompts";
14611
+ var usage15 = `hogsend upgrade [--cwd <dir>] [--pm <pnpm|npm|yarn|bun>] [options]
13355
14612
 
13356
14613
  Upgrade a scaffolded Hogsend app in one step:
13357
14614
  1. bump every @hogsend/* dependency to latest (or --to <version>), then
@@ -13372,23 +14629,23 @@ Options:
13372
14629
  -h, --help Show this help.`;
13373
14630
  var VALID_PMS = ["pnpm", "npm", "yarn", "bun"];
13374
14631
  function detectPm(cwd) {
13375
- if (existsSync7(join7(cwd, "pnpm-lock.yaml"))) return "pnpm";
13376
- if (existsSync7(join7(cwd, "yarn.lock"))) return "yarn";
13377
- if (existsSync7(join7(cwd, "bun.lockb")) || existsSync7(join7(cwd, "bun.lock")))
14632
+ if (existsSync10(join10(cwd, "pnpm-lock.yaml"))) return "pnpm";
14633
+ if (existsSync10(join10(cwd, "yarn.lock"))) return "yarn";
14634
+ if (existsSync10(join10(cwd, "bun.lockb")) || existsSync10(join10(cwd, "bun.lock")))
13378
14635
  return "bun";
13379
- if (existsSync7(join7(cwd, "package-lock.json"))) return "npm";
14636
+ if (existsSync10(join10(cwd, "package-lock.json"))) return "npm";
13380
14637
  return "pnpm";
13381
14638
  }
13382
14639
  function hogsendDeps(cwd) {
13383
- const pkg = JSON.parse(readFileSync4(join7(cwd, "package.json"), "utf8"));
14640
+ const pkg = JSON.parse(readFileSync6(join10(cwd, "package.json"), "utf8"));
13384
14641
  const all = { ...pkg.dependencies, ...pkg.devDependencies };
13385
14642
  return Object.keys(all).filter((n) => n.startsWith("@hogsend/")).sort();
13386
14643
  }
13387
14644
  function addArgs(pm, specs) {
13388
14645
  return [pm === "npm" ? "install" : "add", ...specs];
13389
14646
  }
13390
- async function run13(ctx) {
13391
- const { values: values2 } = parseArgs14({
14647
+ async function run15(ctx) {
14648
+ const { values: values2 } = parseArgs17({
13392
14649
  args: ctx.argv,
13393
14650
  allowPositionals: true,
13394
14651
  options: {
@@ -13402,14 +14659,14 @@ async function run13(ctx) {
13402
14659
  }
13403
14660
  });
13404
14661
  if (values2.help) {
13405
- ctx.out.log(usage13);
14662
+ ctx.out.log(usage15);
13406
14663
  return;
13407
14664
  }
13408
14665
  if (values2["deps-only"] && values2["skills-only"]) {
13409
14666
  ctx.out.fail("--deps-only and --skills-only are mutually exclusive.");
13410
14667
  }
13411
14668
  const cwd = values2.cwd ?? process.cwd();
13412
- if (!existsSync7(join7(cwd, "package.json"))) {
14669
+ if (!existsSync10(join10(cwd, "package.json"))) {
13413
14670
  ctx.out.fail(
13414
14671
  `no package.json in ${cwd} \u2014 run upgrade from a scaffolded Hogsend app (or pass --cwd).`
13415
14672
  );
@@ -13431,7 +14688,7 @@ async function run13(ctx) {
13431
14688
  const deps = doDeps ? hogsendDeps(cwd) : [];
13432
14689
  if (doDeps && deps.length === 0) {
13433
14690
  ctx.out.fail(
13434
- `no @hogsend/* dependencies found in ${join7(cwd, "package.json")}.`
14691
+ `no @hogsend/* dependencies found in ${join10(cwd, "package.json")}.`
13435
14692
  );
13436
14693
  }
13437
14694
  const skipConfirm = ctx.json || values2.yes;
@@ -13446,7 +14703,7 @@ async function run13(ctx) {
13446
14703
  doSkills ? "refresh .claude/skills" : null
13447
14704
  ].filter(Boolean).join(" + ");
13448
14705
  const proceed = bail(
13449
- await confirm2({ message: `Upgrade ${color.cyan(cwd)}: ${plan}?` })
14706
+ await confirm3({ message: `Upgrade ${color.cyan(cwd)}: ${plan}?` })
13450
14707
  );
13451
14708
  if (!proceed) {
13452
14709
  ctx.out.outro(color.dim("Nothing changed."));
@@ -13458,7 +14715,7 @@ async function run13(ctx) {
13458
14715
  const specs = deps.map((n) => `${n}@${target}`);
13459
14716
  const dep = await ctx.out.step(
13460
14717
  `Bumping @hogsend/* -> ${target} (${pm})`,
13461
- async () => spawnSync3(pm, addArgs(pm, specs), {
14718
+ async () => spawnSync4(pm, addArgs(pm, specs), {
13462
14719
  cwd,
13463
14720
  stdio: ctx.json ? "ignore" : "inherit",
13464
14721
  shell: process.platform === "win32"
@@ -13527,12 +14784,12 @@ async function run13(ctx) {
13527
14784
  var upgradeCommand = {
13528
14785
  name: "upgrade",
13529
14786
  summary: "Bump @hogsend/* deps to latest + refresh vendored skills",
13530
- usage: usage13,
13531
- run: run13
14787
+ usage: usage15,
14788
+ run: run15
13532
14789
  };
13533
14790
 
13534
14791
  // src/commands/webhooks.ts
13535
- import { parseArgs as parseArgs15 } from "util";
14792
+ import { parseArgs as parseArgs18 } from "util";
13536
14793
  var WEBHOOK_EVENT_TYPES = [
13537
14794
  "contact.created",
13538
14795
  "contact.updated",
@@ -13548,7 +14805,7 @@ var WEBHOOK_EVENT_TYPES = [
13548
14805
  "bucket.entered",
13549
14806
  "bucket.left"
13550
14807
  ];
13551
- var usage14 = `hogsend webhooks <subcommand> [options]
14808
+ var usage16 = `hogsend webhooks <subcommand> [options]
13552
14809
 
13553
14810
  Manage outbound webhook endpoints \u2014 the Svix-style signed event stream Hogsend
13554
14811
  emits to your URLs. Wraps the admin routes (/v1/admin/webhooks), so this command
@@ -13594,7 +14851,7 @@ Examples:
13594
14851
  hogsend webhooks list --include-disabled
13595
14852
  hogsend webhooks rotate-secret we_123
13596
14853
  hogsend webhooks test we_123`;
13597
- var badge5 = `${color.bgMagenta(color.black(" hogsend "))} webhooks`;
14854
+ var badge6 = `${color.bgMagenta(color.black(" hogsend "))} webhooks`;
13598
14855
  async function fetchOrFail2(ctx, label, fn) {
13599
14856
  try {
13600
14857
  return await ctx.out.step(label, fn);
@@ -13638,7 +14895,7 @@ ${color.bold(secret)}`,
13638
14895
  );
13639
14896
  }
13640
14897
  async function runList5(ctx, argv) {
13641
- const { values: values2 } = parseArgs15({
14898
+ const { values: values2 } = parseArgs18({
13642
14899
  args: argv,
13643
14900
  allowPositionals: true,
13644
14901
  options: {
@@ -13649,7 +14906,7 @@ async function runList5(ctx, argv) {
13649
14906
  }
13650
14907
  });
13651
14908
  if (values2.help) {
13652
- ctx.out.log(usage14);
14909
+ ctx.out.log(usage16);
13653
14910
  return;
13654
14911
  }
13655
14912
  const query = {
@@ -13657,7 +14914,7 @@ async function runList5(ctx, argv) {
13657
14914
  limit: values2.limit,
13658
14915
  offset: values2.offset
13659
14916
  };
13660
- if (!ctx.json) ctx.out.intro(`${badge5} list`);
14917
+ if (!ctx.json) ctx.out.intro(`${badge6} list`);
13661
14918
  const res = await fetchOrFail2(
13662
14919
  ctx,
13663
14920
  "Fetching webhooks",
@@ -13698,13 +14955,13 @@ function renderEndpoint(ctx, ep, title) {
13698
14955
  );
13699
14956
  }
13700
14957
  async function runGet3(ctx, argv) {
13701
- const { values: values2, positionals } = parseArgs15({
14958
+ const { values: values2, positionals } = parseArgs18({
13702
14959
  args: argv,
13703
14960
  allowPositionals: true,
13704
14961
  options: { help: { type: "boolean", short: "h", default: false } }
13705
14962
  });
13706
14963
  if (values2.help) {
13707
- ctx.out.log(usage14);
14964
+ ctx.out.log(usage16);
13708
14965
  return;
13709
14966
  }
13710
14967
  const id = positionals[0];
@@ -13713,7 +14970,7 @@ async function runGet3(ctx, argv) {
13713
14970
  "webhooks get requires an endpoint id, e.g. hogsend webhooks get we_123"
13714
14971
  );
13715
14972
  }
13716
- if (!ctx.json) ctx.out.intro(`${badge5} get`);
14973
+ if (!ctx.json) ctx.out.intro(`${badge6} get`);
13717
14974
  const res = await fetchOrFail2(
13718
14975
  ctx,
13719
14976
  "Fetching webhook",
@@ -13729,7 +14986,7 @@ async function runGet3(ctx, argv) {
13729
14986
  ctx.out.outro(`${res.url} \u2192 ${res.status}`);
13730
14987
  }
13731
14988
  async function runCreate2(ctx, argv) {
13732
- const { values: values2 } = parseArgs15({
14989
+ const { values: values2 } = parseArgs18({
13733
14990
  args: argv,
13734
14991
  allowPositionals: true,
13735
14992
  options: {
@@ -13742,7 +14999,7 @@ async function runCreate2(ctx, argv) {
13742
14999
  }
13743
15000
  });
13744
15001
  if (values2.help) {
13745
- ctx.out.log(usage14);
15002
+ ctx.out.log(usage16);
13746
15003
  return;
13747
15004
  }
13748
15005
  const url = values2.url;
@@ -13760,7 +15017,7 @@ async function runCreate2(ctx, argv) {
13760
15017
  const body = { url, eventTypes };
13761
15018
  if (values2.description !== void 0) body.description = values2.description;
13762
15019
  if (values2.disabled) body.disabled = true;
13763
- if (!ctx.json) ctx.out.intro(`${badge5} create`);
15020
+ if (!ctx.json) ctx.out.intro(`${badge6} create`);
13764
15021
  const res = await fetchOrFail2(
13765
15022
  ctx,
13766
15023
  "Creating webhook",
@@ -13776,7 +15033,7 @@ async function runCreate2(ctx, argv) {
13776
15033
  ctx.out.outro(`${color.green("Created")} ${res.id} \u2192 ${res.url}`);
13777
15034
  }
13778
15035
  async function runUpdate(ctx, argv) {
13779
- const { values: values2, positionals } = parseArgs15({
15036
+ const { values: values2, positionals } = parseArgs18({
13780
15037
  args: argv,
13781
15038
  allowPositionals: true,
13782
15039
  options: {
@@ -13790,7 +15047,7 @@ async function runUpdate(ctx, argv) {
13790
15047
  }
13791
15048
  });
13792
15049
  if (values2.help) {
13793
- ctx.out.log(usage14);
15050
+ ctx.out.log(usage16);
13794
15051
  return;
13795
15052
  }
13796
15053
  const id = positionals[0];
@@ -13814,7 +15071,7 @@ async function runUpdate(ctx, argv) {
13814
15071
  "webhooks update: nothing to change \u2014 pass --url / --event / --description / --disabled / --enabled"
13815
15072
  );
13816
15073
  }
13817
- if (!ctx.json) ctx.out.intro(`${badge5} update`);
15074
+ if (!ctx.json) ctx.out.intro(`${badge6} update`);
13818
15075
  const res = await fetchOrFail2(
13819
15076
  ctx,
13820
15077
  "Updating webhook",
@@ -13831,13 +15088,13 @@ async function runUpdate(ctx, argv) {
13831
15088
  ctx.out.outro(`${color.green("Updated")} ${res.id} \u2192 ${res.status}`);
13832
15089
  }
13833
15090
  async function runDelete(ctx, argv) {
13834
- const { values: values2, positionals } = parseArgs15({
15091
+ const { values: values2, positionals } = parseArgs18({
13835
15092
  args: argv,
13836
15093
  allowPositionals: true,
13837
15094
  options: { help: { type: "boolean", short: "h", default: false } }
13838
15095
  });
13839
15096
  if (values2.help) {
13840
- ctx.out.log(usage14);
15097
+ ctx.out.log(usage16);
13841
15098
  return;
13842
15099
  }
13843
15100
  const id = positionals[0];
@@ -13846,7 +15103,7 @@ async function runDelete(ctx, argv) {
13846
15103
  "webhooks delete requires an endpoint id, e.g. hogsend webhooks delete we_123"
13847
15104
  );
13848
15105
  }
13849
- if (!ctx.json) ctx.out.intro(`${badge5} delete`);
15106
+ if (!ctx.json) ctx.out.intro(`${badge6} delete`);
13850
15107
  const res = await fetchOrFail2(
13851
15108
  ctx,
13852
15109
  "Deleting webhook",
@@ -13861,13 +15118,13 @@ async function runDelete(ctx, argv) {
13861
15118
  ctx.out.outro(`${color.green("Deleted")} ${id}`);
13862
15119
  }
13863
15120
  async function runRotate(ctx, argv) {
13864
- const { values: values2, positionals } = parseArgs15({
15121
+ const { values: values2, positionals } = parseArgs18({
13865
15122
  args: argv,
13866
15123
  allowPositionals: true,
13867
15124
  options: { help: { type: "boolean", short: "h", default: false } }
13868
15125
  });
13869
15126
  if (values2.help) {
13870
- ctx.out.log(usage14);
15127
+ ctx.out.log(usage16);
13871
15128
  return;
13872
15129
  }
13873
15130
  const id = positionals[0];
@@ -13876,7 +15133,7 @@ async function runRotate(ctx, argv) {
13876
15133
  "webhooks rotate-secret requires an endpoint id, e.g. hogsend webhooks rotate-secret we_123"
13877
15134
  );
13878
15135
  }
13879
- if (!ctx.json) ctx.out.intro(`${badge5} rotate-secret`);
15136
+ if (!ctx.json) ctx.out.intro(`${badge6} rotate-secret`);
13880
15137
  const res = await fetchOrFail2(
13881
15138
  ctx,
13882
15139
  "Rotating signing secret",
@@ -13896,13 +15153,13 @@ async function runRotate(ctx, argv) {
13896
15153
  );
13897
15154
  }
13898
15155
  async function runTest(ctx, argv) {
13899
- const { values: values2, positionals } = parseArgs15({
15156
+ const { values: values2, positionals } = parseArgs18({
13900
15157
  args: argv,
13901
15158
  allowPositionals: true,
13902
15159
  options: { help: { type: "boolean", short: "h", default: false } }
13903
15160
  });
13904
15161
  if (values2.help) {
13905
- ctx.out.log(usage14);
15162
+ ctx.out.log(usage16);
13906
15163
  return;
13907
15164
  }
13908
15165
  const id = positionals[0];
@@ -13911,7 +15168,7 @@ async function runTest(ctx, argv) {
13911
15168
  "webhooks test requires an endpoint id, e.g. hogsend webhooks test we_123"
13912
15169
  );
13913
15170
  }
13914
- if (!ctx.json) ctx.out.intro(`${badge5} test`);
15171
+ if (!ctx.json) ctx.out.intro(`${badge6} test`);
13915
15172
  const res = await fetchOrFail2(
13916
15173
  ctx,
13917
15174
  "Enqueuing test delivery",
@@ -13928,7 +15185,7 @@ async function runTest(ctx, argv) {
13928
15185
  `${color.green("Enqueued")} a ${color.cyan(res.eventType)} delivery to ${id}.`
13929
15186
  );
13930
15187
  }
13931
- async function run14(ctx) {
15188
+ async function run16(ctx) {
13932
15189
  const sub = ctx.argv[0];
13933
15190
  switch (sub) {
13934
15191
  case "list":
@@ -13959,8 +15216,8 @@ async function run14(ctx) {
13959
15216
  var webhooksCommand = {
13960
15217
  name: "webhooks",
13961
15218
  summary: "Manage outbound webhook endpoints (create, rotate, test)",
13962
- usage: usage14,
13963
- run: run14
15219
+ usage: usage16,
15220
+ run: run16
13964
15221
  };
13965
15222
 
13966
15223
  // src/commands/index.ts
@@ -13973,7 +15230,9 @@ var commands = [
13973
15230
  emailsCommand,
13974
15231
  campaignsCommand,
13975
15232
  webhooksCommand,
15233
+ domainCommand,
13976
15234
  studioCommand,
15235
+ devCommand,
13977
15236
  setupCommand,
13978
15237
  skillsCommand,
13979
15238
  upgradeCommand,
@@ -13981,87 +15240,6 @@ var commands = [
13981
15240
  patchCommand
13982
15241
  ];
13983
15242
 
13984
- // src/lib/config.ts
13985
- import { existsSync as existsSync8, readFileSync as readFileSync5 } from "fs";
13986
- import { join as join8 } from "path";
13987
- import { parseArgs as parseArgs16 } from "util";
13988
- var DEFAULT_BASE_URL = "http://localhost:3002";
13989
- function parseGlobalFlags(argv) {
13990
- const { values: values2, tokens } = parseArgs16({
13991
- args: argv,
13992
- allowPositionals: true,
13993
- strict: false,
13994
- tokens: true,
13995
- options: {
13996
- url: { type: "string" },
13997
- "admin-key": { type: "string" },
13998
- "data-key": { type: "string" },
13999
- json: { type: "boolean", default: false },
14000
- help: { type: "boolean", short: "h", default: false }
14001
- }
14002
- });
14003
- const owned = /* @__PURE__ */ new Set(["url", "admin-key", "data-key", "json", "help", "h"]);
14004
- const rest = [];
14005
- for (const token of tokens) {
14006
- if (token.kind === "positional") {
14007
- rest.push(token.value);
14008
- } else if (token.kind === "option") {
14009
- if (owned.has(token.name)) continue;
14010
- rest.push(token.rawName);
14011
- if (token.value !== void 0 && !token.inlineValue) {
14012
- rest.push(token.value);
14013
- } else if (token.inlineValue && token.value !== void 0) {
14014
- rest[rest.length - 1] = `${token.rawName}=${token.value}`;
14015
- }
14016
- }
14017
- }
14018
- return {
14019
- url: typeof values2.url === "string" ? values2.url : void 0,
14020
- adminKey: typeof values2["admin-key"] === "string" ? values2["admin-key"] : void 0,
14021
- dataKey: typeof values2["data-key"] === "string" ? values2["data-key"] : void 0,
14022
- json: values2.json === true,
14023
- help: values2.help === true,
14024
- rest
14025
- };
14026
- }
14027
- function loadDotEnv(cwd = process.cwd()) {
14028
- const out = {};
14029
- const file = join8(cwd, ".env");
14030
- if (!existsSync8(file)) return out;
14031
- let raw;
14032
- try {
14033
- raw = readFileSync5(file, "utf8");
14034
- } catch {
14035
- return out;
14036
- }
14037
- for (const rawLine of raw.split(/\r?\n/)) {
14038
- const line2 = rawLine.trim();
14039
- if (line2 === "" || line2.startsWith("#")) continue;
14040
- const withoutExport = line2.startsWith("export ") ? line2.slice("export ".length) : line2;
14041
- const eq2 = withoutExport.indexOf("=");
14042
- if (eq2 === -1) continue;
14043
- const key = withoutExport.slice(0, eq2).trim();
14044
- if (key === "") continue;
14045
- let value = withoutExport.slice(eq2 + 1).trim();
14046
- if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
14047
- value = value.slice(1, -1);
14048
- }
14049
- out[key] = value;
14050
- }
14051
- return out;
14052
- }
14053
- function resolveConfig(flags, cwd = process.cwd()) {
14054
- const dotenv = loadDotEnv(cwd);
14055
- const baseUrlRaw = flags.url ?? process.env.HOGSEND_API_URL ?? dotenv.HOGSEND_API_URL ?? DEFAULT_BASE_URL;
14056
- const adminKey = flags.adminKey ?? process.env.HOGSEND_ADMIN_KEY ?? process.env.ADMIN_API_KEY ?? dotenv.HOGSEND_ADMIN_KEY ?? dotenv.ADMIN_API_KEY;
14057
- const dataKey = flags.dataKey ?? process.env.HOGSEND_DATA_KEY ?? process.env.HOGSEND_API_KEY ?? dotenv.HOGSEND_DATA_KEY ?? dotenv.HOGSEND_API_KEY;
14058
- return {
14059
- baseUrl: baseUrlRaw.replace(/\/+$/, ""),
14060
- adminKey: adminKey && adminKey.length > 0 ? adminKey : void 0,
14061
- dataKey: dataKey && dataKey.length > 0 ? dataKey : void 0
14062
- };
14063
- }
14064
-
14065
15243
  // src/bin.ts
14066
15244
  function version2() {
14067
15245
  try {