@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.
Files changed (63) hide show
  1. package/dist/bin.js +2238 -75
  2. package/dist/bin.js.map +1 -1
  3. package/package.json +9 -1
  4. package/skills/hogsend-authoring-buckets/SKILL.md +69 -0
  5. package/skills/hogsend-authoring-buckets/references/bucket-id-aliases.md +117 -0
  6. package/skills/hogsend-authoring-buckets/references/bucket-meta.md +142 -0
  7. package/skills/hogsend-authoring-buckets/references/buckets-vs-journeys.md +96 -0
  8. package/skills/hogsend-authoring-buckets/references/register-a-bucket.md +129 -0
  9. package/skills/hogsend-authoring-emails/SKILL.md +68 -0
  10. package/skills/hogsend-authoring-emails/references/email-components.md +112 -0
  11. package/skills/hogsend-authoring-emails/references/preview-and-render.md +116 -0
  12. package/skills/hogsend-authoring-emails/references/template-four-file-contract.md +134 -0
  13. package/skills/hogsend-authoring-emails/references/tracking-and-unsubscribe.md +127 -0
  14. package/skills/hogsend-authoring-journeys/SKILL.md +88 -0
  15. package/skills/hogsend-authoring-journeys/references/branch-on-engagement.md +93 -0
  16. package/skills/hogsend-authoring-journeys/references/journey-context.md +110 -0
  17. package/skills/hogsend-authoring-journeys/references/journey-meta.md +142 -0
  18. package/skills/hogsend-authoring-journeys/references/register-a-journey.md +99 -0
  19. package/skills/hogsend-authoring-journeys/references/sending-email-from-a-journey.md +82 -0
  20. package/skills/hogsend-cli/SKILL.md +81 -0
  21. package/skills/hogsend-cli/references/debug-a-journey.md +66 -0
  22. package/skills/hogsend-cli/references/manage-journeys.md +53 -0
  23. package/skills/hogsend-cli/references/query-stats.md +66 -0
  24. package/skills/hogsend-cli/references/setup-local.md +52 -0
  25. package/skills/hogsend-conditions/SKILL.md +70 -0
  26. package/skills/hogsend-conditions/references/condition-types.md +251 -0
  27. package/skills/hogsend-conditions/references/durations.md +90 -0
  28. package/skills/hogsend-conditions/references/examples.md +188 -0
  29. package/skills/hogsend-database/SKILL.md +70 -0
  30. package/skills/hogsend-database/references/client-track-schema.md +97 -0
  31. package/skills/hogsend-database/references/migrations.md +132 -0
  32. package/skills/hogsend-database/references/schema-drift.md +123 -0
  33. package/skills/hogsend-deploy/SKILL.md +62 -0
  34. package/skills/hogsend-deploy/references/env-and-secrets.md +118 -0
  35. package/skills/hogsend-deploy/references/railway-two-services.md +122 -0
  36. package/skills/hogsend-deploy/references/upgrade-engine.md +92 -0
  37. package/skills/hogsend-webhooks-and-workflows/SKILL.md +68 -0
  38. package/skills/hogsend-webhooks-and-workflows/references/backfill-pattern.md +148 -0
  39. package/skills/hogsend-webhooks-and-workflows/references/custom-workflow.md +156 -0
  40. package/skills/hogsend-webhooks-and-workflows/references/webhook-source.md +172 -0
  41. package/src/bin.ts +73 -111
  42. package/src/commands/contacts.ts +316 -0
  43. package/src/commands/doctor.ts +239 -0
  44. package/src/commands/eject.ts +106 -0
  45. package/src/commands/events.ts +154 -0
  46. package/src/commands/index.ts +36 -0
  47. package/src/commands/journeys.ts +343 -0
  48. package/src/commands/patch.ts +80 -0
  49. package/src/commands/setup.ts +322 -0
  50. package/src/commands/skills.ts +208 -0
  51. package/src/commands/stats.ts +87 -0
  52. package/src/commands/studio.ts +261 -0
  53. package/src/commands/types.ts +41 -0
  54. package/src/commands/upgrade.ts +245 -0
  55. package/src/index.ts +2 -0
  56. package/src/lib/config.ts +147 -0
  57. package/src/lib/http.ts +145 -0
  58. package/src/lib/output.ts +185 -0
  59. package/src/lib/prompt.ts +17 -0
  60. package/src/lib/skills.ts +186 -0
  61. package/studio/assets/index-BVA9GZqq.css +1 -0
  62. package/studio/assets/index-kPwzOOyG.js +230 -0
  63. 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 { 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
+ };