@hogsend/cli 0.0.1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,66 @@
1
+ # Query metrics, contacts, and events
2
+
3
+ Read-only analysis over a running Hogsend instance. Always pass `--json` when
4
+ parsing.
5
+
6
+ ## Overview metrics
7
+
8
+ ```bash
9
+ hogsend stats --json
10
+ ```
11
+
12
+ Wraps `GET /v1/admin/metrics/overview`. Returns (shape may vary slightly by
13
+ version):
14
+
15
+ ```json
16
+ {
17
+ "totalContacts": 1234,
18
+ "activeJourneys": 5,
19
+ "emailsSent24h": 88,
20
+ "emailsSent7d": 540,
21
+ "emailsSent30d": 2100,
22
+ "bounceRate30d": 0.012,
23
+ "unsubscribeRate": 0.004
24
+ }
25
+ ```
26
+
27
+ Use this for a one-shot snapshot. `bounceRate30d` / `unsubscribeRate` are
28
+ fractions (multiply by 100 for a percentage). A rising bounce rate is the first
29
+ signal of a deliverability problem.
30
+
31
+ ## Contacts
32
+
33
+ ```bash
34
+ # List with search + pagination
35
+ hogsend contacts list --search "@acme.com" --limit 50 --offset 0 --json
36
+
37
+ # A single contact by internal id OR externalId
38
+ hogsend contacts get user_123 --json
39
+
40
+ # Merged activity timeline (events + emails + journeys) for one contact
41
+ hogsend contacts timeline user_123 --json
42
+ ```
43
+
44
+ Wraps `GET /v1/admin/contacts`, `GET /v1/admin/contacts/{id}`, and
45
+ `GET /v1/admin/contacts/{id}/timeline`. `get` includes the contact record plus
46
+ email preferences (subscribed / unsubscribed). The timeline is the fastest way
47
+ to understand a single user's lifecycle history.
48
+
49
+ ## Raw event stream
50
+
51
+ ```bash
52
+ hogsend events user_123 --json
53
+ hogsend events user_123 --event "checkout.completed" --from 2026-01-01T00:00:00Z --to 2026-02-01T00:00:00Z --limit 100 --json
54
+ ```
55
+
56
+ Wraps `GET /v1/admin/events?userId=<userId>`. Filter by `--event`, time window
57
+ (`--from`/`--to`, ISO 8601), and paginate with `--limit`/`--offset`. Use this
58
+ when the timeline isn't granular enough and you need the exact event payloads
59
+ (e.g. to see which properties were present when a journey trigger fired).
60
+
61
+ ## Analysis pattern
62
+
63
+ 1. `hogsend stats --json` for the macro picture.
64
+ 2. `hogsend contacts list --search ... --json` to find the cohort.
65
+ 3. `hogsend contacts timeline <id> --json` to understand individual journeys.
66
+ 4. `hogsend events <id> --event <name> --json` to inspect exact payloads.
@@ -0,0 +1,52 @@
1
+ # Set up a local Hogsend instance
2
+
3
+ `hogsend setup` is interactive LOCAL onboarding — it mirrors the "next steps"
4
+ that `create-hogsend` prints after scaffolding. It is NOT a Railway / cloud
5
+ deploy flow.
6
+
7
+ ## Run it
8
+
9
+ ```bash
10
+ hogsend setup
11
+ ```
12
+
13
+ What it does (interactively, with clack prompts + spinners):
14
+
15
+ 1. `docker compose up -d` — starts TimescaleDB (Postgres), Redis, and
16
+ Hatchet-Lite locally.
17
+ 2. Generates a `BETTER_AUTH_SECRET`.
18
+ 3. Copies `.env.example` to `.env` if `.env` doesn't already exist (it won't
19
+ clobber an existing `.env`).
20
+ 4. Runs `db:migrate` to apply the database schema.
21
+
22
+ Run it from your Hogsend project root (the directory with `docker-compose.yml`
23
+ and `.env.example`).
24
+
25
+ ## Verify
26
+
27
+ After setup, confirm the instance is healthy and the schema is in sync:
28
+
29
+ ```bash
30
+ hogsend doctor --json
31
+ ```
32
+
33
+ Expect a `ok` verdict with `database` and `redis` components healthy and
34
+ `schema.inSync = true`. If you see `migration_pending`, re-run the migration
35
+ step; if `unreachable`, the API isn't running yet — start it with `pnpm dev`
36
+ (default port 3002), then re-run doctor.
37
+
38
+ ## Typical first session
39
+
40
+ ```bash
41
+ hogsend setup # docker up, secret, .env, migrate
42
+ pnpm dev # start the API on :3002 (separate terminal)
43
+ hogsend doctor --json # confirm ok
44
+ hogsend stats --json # sanity-check metrics endpoint
45
+ ```
46
+
47
+ ## Notes
48
+
49
+ - `setup` is interactive by design; in a non-interactive / agent context,
50
+ prefer running the underlying steps explicitly and then `hogsend doctor`.
51
+ - The admin key for local reads comes from your `.env` (`ADMIN_API_KEY` or
52
+ `HOGSEND_ADMIN_KEY`); `doctor` itself needs no key.
package/src/bin.ts CHANGED
@@ -1,140 +1,102 @@
1
1
  #!/usr/bin/env node
2
- import { existsSync, realpathSync } from "node:fs";
3
2
  import { createRequire } from "node:module";
4
- import { dirname, join, sep } from "node:path";
5
- import { parseArgs } from "node:util";
6
- import { EjectError, eject } from "./eject.js";
3
+ import { commands } from "./commands/index.js";
4
+ import type { Command } from "./commands/types.js";
5
+ import { parseGlobalFlags, resolveConfig } from "./lib/config.js";
6
+ import { createAdminClient } from "./lib/http.js";
7
+ import { color, createOutput } from "./lib/output.js";
7
8
 
8
- const USAGE = `hogsend — Hogsend project CLI
9
-
10
- Usage:
11
- hogsend eject <package> [--force] [--cwd <dir>]
9
+ function version(): string {
10
+ try {
11
+ const require = createRequire(import.meta.url);
12
+ const pkg = require("../package.json") as { version?: string };
13
+ return pkg.version ?? "0.0.0";
14
+ } catch {
15
+ return "0.0.0";
16
+ }
17
+ }
12
18
 
13
- Commands:
14
- eject <package> Copy a @hogsend/* package's source into vendor/<name> and
15
- rewrite the consumer dependency to file:./vendor/<name>.
16
- Every other dependency keeps upgrading via pnpm up.
19
+ function rootUsage(): string {
20
+ const longest = commands.reduce((n, c) => Math.max(n, c.name.length), 0);
21
+ const list = commands
22
+ .map((c) => ` ${color.cyan(c.name.padEnd(longest))} ${c.summary}`)
23
+ .join("\n");
24
+ return `${color.bold("hogsend")} — the agent-native Hogsend CLI
17
25
 
18
- Options:
19
- --force Overwrite an existing vendor/<name>.
20
- --cwd <dir> Consumer repo root (defaults to the current directory).
21
- -h, --help Show this help.
26
+ ${color.dim("Usage:")} hogsend <command> [options]
22
27
 
23
- After ejecting, run: pnpm install`;
28
+ ${color.dim("Commands:")}
29
+ ${list}
24
30
 
25
- const RED = "\x1b[31m";
26
- const RESET = "\x1b[0m";
31
+ ${color.dim("Global options:")}
32
+ --url <baseUrl> Target instance (default HOGSEND_API_URL or http://localhost:3002)
33
+ --admin-key <key> Admin bearer token (default HOGSEND_ADMIN_KEY / ADMIN_API_KEY)
34
+ --json Emit machine-readable JSON only (for agents)
35
+ -h, --help Show help (use after a command for command help)
36
+ -v, --version Show version
27
37
 
28
- function fail(message: string): never {
29
- process.stderr.write(`${RED}error${RESET} ${message}\n`);
30
- process.exit(1);
38
+ Run ${color.cyan("hogsend <command> --help")} for command-specific options.`;
31
39
  }
32
40
 
33
- /**
34
- * Resolves the on-disk source directory for an installed package from the
35
- * consumer root. Works for pnpm's `.pnpm` layout and workspace symlinks.
36
- *
37
- * Strategy 1 (primary): probe `<consumerRoot>/node_modules/<pkg>/package.json`
38
- * directly, following the symlink to its real location. This is the common
39
- * layout and — unlike `require.resolve("<pkg>/package.json")` — is NOT blocked
40
- * by the package's `exports` map (most packages don't expose `./package.json`).
41
- *
42
- * Strategy 2 (fallback): resolve the package's main entry via `createRequire`
43
- * and walk up to the nearest directory that contains a package.json whose
44
- * `name` matches.
45
- */
46
- function resolveSourceDir(pkg: string, consumerRoot: string): string {
47
- const direct = join(consumerRoot, "node_modules", pkg, "package.json");
48
- if (existsSync(direct)) {
49
- // realpath follows pnpm/workspace symlinks to the actual source dir.
50
- return dirname(realpathSync(direct));
51
- }
52
-
53
- const require = createRequire(`${consumerRoot}${sep}`);
54
- try {
55
- // Resolving the entry point works even when `./package.json` is not an
56
- // exported subpath; we then walk up to the package root.
57
- const entry = require.resolve(pkg);
58
- let dir = dirname(entry);
59
- while (dir !== dirname(dir)) {
60
- const candidate = join(dir, "package.json");
61
- if (existsSync(candidate)) {
62
- return dir;
63
- }
64
- dir = dirname(dir);
65
- }
66
- } catch {
67
- // fall through to the failure below
68
- }
69
-
70
- fail(
71
- `cannot resolve ${pkg} from ${consumerRoot}. Is it installed? Run pnpm install first.`,
72
- );
41
+ function findCommand(name: string): Command | undefined {
42
+ return commands.find((c) => c.name === name);
73
43
  }
74
44
 
75
- async function runEject(args: string[]): Promise<void> {
76
- const { values, positionals } = parseArgs({
77
- args,
78
- allowPositionals: true,
79
- options: {
80
- force: { type: "boolean", default: false },
81
- cwd: { type: "string" },
82
- help: { type: "boolean", short: "h", default: false },
83
- },
84
- });
45
+ async function main(): Promise<void> {
46
+ const argv = process.argv.slice(2);
47
+ const [token, ...afterToken] = argv;
85
48
 
86
- if (values.help) {
87
- process.stdout.write(`${USAGE}\n`);
49
+ // Version is a top-level concern (before flag parsing).
50
+ if (token === "-v" || token === "--version") {
51
+ process.stdout.write(`${version()}\n`);
88
52
  return;
89
53
  }
90
54
 
91
- const pkg = positionals[0];
92
- if (!pkg) {
93
- fail("eject requires a package name, e.g. hogsend eject @hogsend/engine");
55
+ // No command, or a root-level help request.
56
+ if (!token || token === "-h" || token === "--help") {
57
+ process.stdout.write(`${rootUsage()}\n`);
58
+ return;
94
59
  }
95
60
 
96
- const consumerRoot = values.cwd ?? process.cwd();
97
- const sourceDir = resolveSourceDir(pkg, consumerRoot);
98
-
99
- try {
100
- const result = await eject({
101
- pkg,
102
- consumerRoot,
103
- sourceDir,
104
- force: values.force,
105
- });
106
- process.stdout.write(
107
- `Ejected ${result.pkg}\n` +
108
- ` copied ${result.copiedFiles} files -> ${result.vendorPath}\n` +
109
- ` dependency ${result.depSpecBefore} -> ${result.depSpecAfter}\n` +
110
- `\nNow run: ${result.followUp}\n`,
61
+ const command = findCommand(token);
62
+ if (!command) {
63
+ // Unknown command: report on stderr and show usage. Not json-gated since
64
+ // there's no resolved Output yet.
65
+ process.stderr.write(
66
+ `${color.red("error")} unknown command "${token}"\n\n${rootUsage()}\n`,
111
67
  );
112
- } catch (error) {
113
- if (error instanceof EjectError) {
114
- fail(error.message);
115
- }
116
- throw error;
68
+ process.exit(1);
117
69
  }
118
- }
119
70
 
120
- async function main(): Promise<void> {
121
- const [command, ...rest] = process.argv.slice(2);
71
+ // Parse global flags off the post-token argv; the rest is the command's argv.
72
+ const flags = parseGlobalFlags(afterToken);
73
+ const out = createOutput({ json: flags.json });
122
74
 
123
- if (!command || command === "--help" || command === "-h") {
124
- process.stdout.write(`${USAGE}\n`);
75
+ // `hogsend <cmd> --help` short-circuits to the command's usage block.
76
+ if (flags.help) {
77
+ out.log(command.usage);
125
78
  return;
126
79
  }
127
80
 
128
- switch (command) {
129
- case "eject":
130
- await runEject(rest);
131
- break;
132
- default:
133
- fail(`unknown command "${command}"\n\n${USAGE}`);
134
- }
81
+ const cfg = resolveConfig(flags);
82
+ const http = createAdminClient(cfg);
83
+
84
+ await command.run({
85
+ argv: flags.rest,
86
+ cfg,
87
+ http,
88
+ out,
89
+ json: flags.json,
90
+ });
135
91
  }
136
92
 
137
- main().catch((error) => {
138
- process.stderr.write(`${RED}error${RESET} ${String(error)}\n`);
93
+ main().catch((error: unknown) => {
94
+ const msg = error instanceof Error ? error.message : String(error);
95
+ // Best-effort json detection for top-level failures (Output may not exist).
96
+ if (process.argv.includes("--json")) {
97
+ process.stdout.write(`${JSON.stringify({ error: msg })}\n`);
98
+ } else {
99
+ process.stderr.write(`${color.red("error")} ${msg}\n`);
100
+ }
139
101
  process.exit(1);
140
102
  });
@@ -0,0 +1,316 @@
1
+ import { parseArgs } from "node:util";
2
+ import { isHttpError } from "../lib/http.js";
3
+ import { color } from "../lib/output.js";
4
+ import type { Command, CommandContext } from "./types.js";
5
+
6
+ const usage = `hogsend contacts <subcommand> [options]
7
+
8
+ Inspect contacts via the running app's admin API (/v1/admin/contacts).
9
+
10
+ Subcommands:
11
+ list List contacts (newest activity first).
12
+ get <id> Get one contact (by id or externalId) + preferences.
13
+ timeline <id> Merged event/email/journey activity for a contact.
14
+
15
+ list options:
16
+ --search <q> Filter by email/externalId substring.
17
+ --limit <n> Page size (1-100, default 50).
18
+ --offset <n> Page offset (default 0).
19
+
20
+ timeline options:
21
+ --type <t> Restrict to one of: event | journey | email.
22
+ --limit <n> Page size (1-100, default 50).
23
+ --offset <n> Page offset (default 0).
24
+
25
+ Global options (handled by the router): --url, --admin-key, --json, -h/--help.
26
+
27
+ Examples:
28
+ hogsend contacts list --search acme@ --json
29
+ hogsend contacts get user_123
30
+ hogsend contacts timeline user_123 --type email --json`;
31
+
32
+ type ContactRecord = {
33
+ id: string;
34
+ externalId: string;
35
+ email: string | null;
36
+ properties: Record<string, unknown>;
37
+ firstSeenAt: string;
38
+ lastSeenAt: string;
39
+ createdAt: string;
40
+ updatedAt: string;
41
+ };
42
+
43
+ type Preferences = {
44
+ id: string;
45
+ userId: string;
46
+ email: string;
47
+ unsubscribedAll: boolean;
48
+ suppressed: boolean;
49
+ bounceCount: number;
50
+ categories: Record<string, boolean>;
51
+ } | null;
52
+
53
+ type ListResponse = {
54
+ contacts: ContactRecord[];
55
+ total: number;
56
+ limit: number;
57
+ offset: number;
58
+ };
59
+
60
+ type GetResponse = {
61
+ contact: ContactRecord;
62
+ preferences: Preferences;
63
+ };
64
+
65
+ type TimelineEntry = {
66
+ type: "event" | "journey" | "email";
67
+ timestamp: string;
68
+ data: Record<string, unknown>;
69
+ };
70
+
71
+ type TimelineResponse = {
72
+ timeline: TimelineEntry[];
73
+ total: number;
74
+ limit: number;
75
+ offset: number;
76
+ };
77
+
78
+ const badge = `${color.bgMagenta(color.black(" hogsend "))} contacts`;
79
+
80
+ /** Run an HTTP call, mapping HttpError into a clean ctx.out.fail message. */
81
+ async function fetchOrFail<T>(
82
+ ctx: CommandContext,
83
+ label: string,
84
+ fn: () => Promise<T>,
85
+ ): Promise<T> {
86
+ try {
87
+ return await ctx.out.step(label, fn);
88
+ } catch (err) {
89
+ if (isHttpError(err)) {
90
+ if (err.status === 404) {
91
+ ctx.out.fail(err.message || "contact not found");
92
+ }
93
+ ctx.out.fail(err.message);
94
+ }
95
+ throw err;
96
+ }
97
+ }
98
+
99
+ async function runList(ctx: CommandContext, argv: string[]): Promise<void> {
100
+ const { values } = parseArgs({
101
+ args: argv,
102
+ allowPositionals: true,
103
+ options: {
104
+ search: { type: "string" },
105
+ limit: { type: "string" },
106
+ offset: { type: "string" },
107
+ help: { type: "boolean", short: "h", default: false },
108
+ },
109
+ });
110
+
111
+ if (values.help) {
112
+ ctx.out.log(usage);
113
+ return;
114
+ }
115
+
116
+ const query = {
117
+ search: values.search,
118
+ limit: values.limit,
119
+ offset: values.offset,
120
+ };
121
+
122
+ if (!ctx.json) ctx.out.intro(`${badge} list`);
123
+
124
+ const res = await fetchOrFail<ListResponse>(ctx, "Fetching contacts", () =>
125
+ ctx.http.get<ListResponse>("/v1/admin/contacts", query),
126
+ );
127
+
128
+ if (ctx.json) {
129
+ ctx.out.json(res);
130
+ return;
131
+ }
132
+
133
+ ctx.out.table(
134
+ res.contacts.map((cnt) => ({
135
+ id: cnt.id,
136
+ externalId: cnt.externalId,
137
+ email: cnt.email ?? color.dim("(none)"),
138
+ lastSeenAt: cnt.lastSeenAt,
139
+ })),
140
+ ["id", "externalId", "email", "lastSeenAt"],
141
+ );
142
+ ctx.out.outro(
143
+ `${res.contacts.length} of ${res.total} contact(s) — offset ${res.offset}, limit ${res.limit}`,
144
+ );
145
+ }
146
+
147
+ async function runGet(ctx: CommandContext, argv: string[]): Promise<void> {
148
+ const { values, positionals } = parseArgs({
149
+ args: argv,
150
+ allowPositionals: true,
151
+ options: {
152
+ help: { type: "boolean", short: "h", default: false },
153
+ },
154
+ });
155
+
156
+ if (values.help) {
157
+ ctx.out.log(usage);
158
+ return;
159
+ }
160
+
161
+ // positionals[0] is the "get" subcommand token; the id follows it.
162
+ const id = positionals[1];
163
+ if (!id) {
164
+ ctx.out.fail(
165
+ "contacts get requires an id, e.g. hogsend contacts get user_123",
166
+ );
167
+ }
168
+
169
+ if (!ctx.json) ctx.out.intro(`${badge} get`);
170
+
171
+ const res = await fetchOrFail<GetResponse>(ctx, "Fetching contact", () =>
172
+ ctx.http.get<GetResponse>(`/v1/admin/contacts/${encodeURIComponent(id)}`),
173
+ );
174
+
175
+ if (ctx.json) {
176
+ ctx.out.json(res);
177
+ return;
178
+ }
179
+
180
+ const { contact, preferences } = res;
181
+ ctx.out.kv(
182
+ {
183
+ id: contact.id,
184
+ externalId: contact.externalId,
185
+ email: contact.email ?? color.dim("(none)"),
186
+ firstSeenAt: contact.firstSeenAt,
187
+ lastSeenAt: contact.lastSeenAt,
188
+ properties: contact.properties,
189
+ },
190
+ "Contact",
191
+ );
192
+
193
+ if (preferences) {
194
+ ctx.out.kv(
195
+ {
196
+ unsubscribedAll: preferences.unsubscribedAll,
197
+ suppressed: preferences.suppressed,
198
+ bounceCount: preferences.bounceCount,
199
+ categories: preferences.categories,
200
+ },
201
+ "Preferences",
202
+ );
203
+ } else {
204
+ ctx.out.log(color.dim("No email preferences on record."));
205
+ }
206
+
207
+ ctx.out.outro(`Contact ${color.cyan(contact.externalId)}`);
208
+ }
209
+
210
+ async function runTimeline(ctx: CommandContext, argv: string[]): Promise<void> {
211
+ const { values, positionals } = parseArgs({
212
+ args: argv,
213
+ allowPositionals: true,
214
+ options: {
215
+ type: { type: "string" },
216
+ limit: { type: "string" },
217
+ offset: { type: "string" },
218
+ help: { type: "boolean", short: "h", default: false },
219
+ },
220
+ });
221
+
222
+ if (values.help) {
223
+ ctx.out.log(usage);
224
+ return;
225
+ }
226
+
227
+ // positionals[0] is the "timeline" subcommand token; the id follows it.
228
+ const id = positionals[1];
229
+ if (!id) {
230
+ ctx.out.fail(
231
+ "contacts timeline requires an id, e.g. hogsend contacts timeline user_123",
232
+ );
233
+ }
234
+
235
+ if (values.type && !["event", "journey", "email"].includes(values.type)) {
236
+ ctx.out.fail("--type must be one of: event, journey, email");
237
+ }
238
+
239
+ const query = {
240
+ type: values.type,
241
+ limit: values.limit,
242
+ offset: values.offset,
243
+ };
244
+
245
+ if (!ctx.json) ctx.out.intro(`${badge} timeline`);
246
+
247
+ const res = await fetchOrFail<TimelineResponse>(
248
+ ctx,
249
+ "Fetching timeline",
250
+ () =>
251
+ ctx.http.get<TimelineResponse>(
252
+ `/v1/admin/contacts/${encodeURIComponent(id)}/timeline`,
253
+ query,
254
+ ),
255
+ );
256
+
257
+ if (ctx.json) {
258
+ ctx.out.json(res);
259
+ return;
260
+ }
261
+
262
+ ctx.out.table(
263
+ res.timeline.map((entry) => ({
264
+ timestamp: entry.timestamp,
265
+ type: entry.type,
266
+ summary: summarizeTimelineEntry(entry),
267
+ })),
268
+ ["timestamp", "type", "summary"],
269
+ );
270
+ ctx.out.outro(
271
+ `${res.timeline.length} of ${res.total} entry(s) — offset ${res.offset}, limit ${res.limit}`,
272
+ );
273
+ }
274
+
275
+ /** One-line human description of a timeline entry, by type. */
276
+ function summarizeTimelineEntry(entry: TimelineEntry): string {
277
+ const d = entry.data;
278
+ if (entry.type === "event") {
279
+ return String(d.event ?? "");
280
+ }
281
+ if (entry.type === "journey") {
282
+ return `${String(d.journeyId ?? "")} (${String(d.status ?? "")})`;
283
+ }
284
+ // email
285
+ const subject = d.subject ? String(d.subject) : String(d.templateKey ?? "");
286
+ return `${subject} [${String(d.status ?? "")}]`;
287
+ }
288
+
289
+ async function run(ctx: CommandContext): Promise<void> {
290
+ const sub = ctx.argv[0];
291
+
292
+ switch (sub) {
293
+ case "list":
294
+ return runList(ctx, ctx.argv);
295
+ case "get":
296
+ return runGet(ctx, ctx.argv);
297
+ case "timeline":
298
+ return runTimeline(ctx, ctx.argv);
299
+ case undefined:
300
+ ctx.out.fail(
301
+ "contacts requires a subcommand: list, get, or timeline (see hogsend contacts --help)",
302
+ );
303
+ break;
304
+ default:
305
+ ctx.out.fail(
306
+ `unknown contacts subcommand "${sub}" — expected list, get, or timeline`,
307
+ );
308
+ }
309
+ }
310
+
311
+ export const contactsCommand: Command = {
312
+ name: "contacts",
313
+ summary: "List, inspect, and trace contact activity",
314
+ usage,
315
+ run,
316
+ };