@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.
- package/dist/bin.js +1750 -58
- package/dist/bin.js.map +1 -1
- package/package.json +6 -1
- package/skills/hogsend-cli/SKILL.md +80 -0
- package/skills/hogsend-cli/references/debug-a-journey.md +66 -0
- package/skills/hogsend-cli/references/manage-journeys.md +53 -0
- package/skills/hogsend-cli/references/query-stats.md +66 -0
- package/skills/hogsend-cli/references/setup-local.md +52 -0
- package/src/bin.ts +73 -111
- package/src/commands/contacts.ts +316 -0
- package/src/commands/doctor.ts +217 -0
- package/src/commands/eject.ts +106 -0
- package/src/commands/events.ts +154 -0
- package/src/commands/index.ts +32 -0
- package/src/commands/journeys.ts +343 -0
- package/src/commands/patch.ts +80 -0
- package/src/commands/setup.ts +322 -0
- package/src/commands/skills.ts +268 -0
- package/src/commands/stats.ts +87 -0
- package/src/commands/types.ts +41 -0
- package/src/index.ts +2 -0
- package/src/lib/config.ts +147 -0
- package/src/lib/http.ts +145 -0
- package/src/lib/output.ts +185 -0
- package/src/lib/prompt.ts +17 -0
|
@@ -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 {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
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
|
-
|
|
28
|
+
${color.dim("Commands:")}
|
|
29
|
+
${list}
|
|
24
30
|
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
76
|
-
const
|
|
77
|
-
|
|
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
|
-
|
|
87
|
-
|
|
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
|
-
|
|
92
|
-
if (!
|
|
93
|
-
|
|
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
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
113
|
-
if (error instanceof EjectError) {
|
|
114
|
-
fail(error.message);
|
|
115
|
-
}
|
|
116
|
-
throw error;
|
|
68
|
+
process.exit(1);
|
|
117
69
|
}
|
|
118
|
-
}
|
|
119
70
|
|
|
120
|
-
|
|
121
|
-
const
|
|
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
|
-
|
|
124
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
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
|
+
};
|