@hogsend/cli 0.0.1 → 0.2.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 +2238 -75
- package/dist/bin.js.map +1 -1
- package/package.json +9 -1
- package/skills/hogsend-authoring-buckets/SKILL.md +69 -0
- package/skills/hogsend-authoring-buckets/references/bucket-id-aliases.md +117 -0
- package/skills/hogsend-authoring-buckets/references/bucket-meta.md +142 -0
- package/skills/hogsend-authoring-buckets/references/buckets-vs-journeys.md +96 -0
- package/skills/hogsend-authoring-buckets/references/register-a-bucket.md +129 -0
- package/skills/hogsend-authoring-emails/SKILL.md +68 -0
- package/skills/hogsend-authoring-emails/references/email-components.md +112 -0
- package/skills/hogsend-authoring-emails/references/preview-and-render.md +116 -0
- package/skills/hogsend-authoring-emails/references/template-four-file-contract.md +134 -0
- package/skills/hogsend-authoring-emails/references/tracking-and-unsubscribe.md +127 -0
- package/skills/hogsend-authoring-journeys/SKILL.md +88 -0
- package/skills/hogsend-authoring-journeys/references/branch-on-engagement.md +93 -0
- package/skills/hogsend-authoring-journeys/references/journey-context.md +110 -0
- package/skills/hogsend-authoring-journeys/references/journey-meta.md +142 -0
- package/skills/hogsend-authoring-journeys/references/register-a-journey.md +99 -0
- package/skills/hogsend-authoring-journeys/references/sending-email-from-a-journey.md +82 -0
- package/skills/hogsend-cli/SKILL.md +81 -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/skills/hogsend-conditions/SKILL.md +70 -0
- package/skills/hogsend-conditions/references/condition-types.md +251 -0
- package/skills/hogsend-conditions/references/durations.md +90 -0
- package/skills/hogsend-conditions/references/examples.md +188 -0
- package/skills/hogsend-database/SKILL.md +70 -0
- package/skills/hogsend-database/references/client-track-schema.md +97 -0
- package/skills/hogsend-database/references/migrations.md +132 -0
- package/skills/hogsend-database/references/schema-drift.md +123 -0
- package/skills/hogsend-deploy/SKILL.md +62 -0
- package/skills/hogsend-deploy/references/env-and-secrets.md +118 -0
- package/skills/hogsend-deploy/references/railway-two-services.md +122 -0
- package/skills/hogsend-deploy/references/upgrade-engine.md +92 -0
- package/skills/hogsend-webhooks-and-workflows/SKILL.md +68 -0
- package/skills/hogsend-webhooks-and-workflows/references/backfill-pattern.md +148 -0
- package/skills/hogsend-webhooks-and-workflows/references/custom-workflow.md +156 -0
- package/skills/hogsend-webhooks-and-workflows/references/webhook-source.md +172 -0
- package/src/bin.ts +73 -111
- package/src/commands/contacts.ts +316 -0
- package/src/commands/doctor.ts +239 -0
- package/src/commands/eject.ts +106 -0
- package/src/commands/events.ts +154 -0
- package/src/commands/index.ts +36 -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 +208 -0
- package/src/commands/stats.ts +87 -0
- package/src/commands/studio.ts +261 -0
- package/src/commands/types.ts +41 -0
- package/src/commands/upgrade.ts +245 -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
- package/src/lib/skills.ts +186 -0
- package/studio/assets/index-BVA9GZqq.css +1 -0
- package/studio/assets/index-kPwzOOyG.js +230 -0
- package/studio/index.html +13 -0
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
|
+
};
|