@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
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { parseArgs } from "node:util";
|
|
2
|
+
import { isHttpError } from "../lib/http.js";
|
|
3
|
+
import { color } from "../lib/output.js";
|
|
4
|
+
import { skillsStaleness } from "../lib/skills.js";
|
|
5
|
+
import type { Command, CommandContext } from "./types.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Best-effort nudge: if the cwd is a Hogsend app whose vendored skills were
|
|
9
|
+
* installed by an OLDER CLI than the one running now, point the user at the
|
|
10
|
+
* refresh. Silent when there's no stamp (not an app dir / never tracked).
|
|
11
|
+
*/
|
|
12
|
+
function skillsNudge(ctx: CommandContext): void {
|
|
13
|
+
const verdict = skillsStaleness(process.cwd());
|
|
14
|
+
if (!verdict?.stale || ctx.json) return;
|
|
15
|
+
ctx.out.note(
|
|
16
|
+
[
|
|
17
|
+
`Vendored Claude skills are from v${verdict.installed}; this CLI is v${verdict.current}.`,
|
|
18
|
+
"",
|
|
19
|
+
`Refresh: ${color.cyan("hogsend upgrade")} ${color.dim("(deps + skills)")} or ${color.cyan("hogsend skills add --all --force")}.`,
|
|
20
|
+
].join("\n"),
|
|
21
|
+
"Skills out of date",
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const usage = `hogsend doctor [--url <baseUrl>] [--admin-key <key>] [--json]
|
|
26
|
+
|
|
27
|
+
Probe a running Hogsend instance via GET /v1/health and report its health:
|
|
28
|
+
component status (database, redis), two-track schema state (engine + client),
|
|
29
|
+
and an overall verdict.
|
|
30
|
+
|
|
31
|
+
The health route is unauthenticated, so doctor works without an admin key.
|
|
32
|
+
|
|
33
|
+
Verdict:
|
|
34
|
+
ok service healthy, all components up, schema in sync
|
|
35
|
+
degraded reachable but a component (database/redis) is down
|
|
36
|
+
migration_pending reachable but a schema track is behind (pending migrations)
|
|
37
|
+
unreachable the instance could not be reached at all
|
|
38
|
+
|
|
39
|
+
Exit code: 0 when ok, 1 when unreachable / degraded / migration_pending.
|
|
40
|
+
|
|
41
|
+
Options:
|
|
42
|
+
--url <baseUrl> Target instance (default HOGSEND_API_URL / .env / :3002).
|
|
43
|
+
--admin-key <key> Unused by doctor (health is unauthenticated).
|
|
44
|
+
--json Emit machine-readable JSON only.
|
|
45
|
+
-h, --help Show this help.`;
|
|
46
|
+
|
|
47
|
+
/** Subset of the engine /v1/health response we render. */
|
|
48
|
+
interface HealthComponent {
|
|
49
|
+
status: "up" | "down";
|
|
50
|
+
latencyMs?: number;
|
|
51
|
+
}
|
|
52
|
+
interface HealthTrack {
|
|
53
|
+
applied: string | null;
|
|
54
|
+
required: string | null;
|
|
55
|
+
inSync: boolean;
|
|
56
|
+
pending: string[];
|
|
57
|
+
}
|
|
58
|
+
interface HealthResponse {
|
|
59
|
+
status: "healthy" | "degraded" | "migration_pending";
|
|
60
|
+
uptime: number;
|
|
61
|
+
timestamp: string;
|
|
62
|
+
version: string;
|
|
63
|
+
components: {
|
|
64
|
+
database: HealthComponent;
|
|
65
|
+
redis: HealthComponent;
|
|
66
|
+
};
|
|
67
|
+
schema: {
|
|
68
|
+
engine: HealthTrack;
|
|
69
|
+
client: HealthTrack;
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
type Verdict = "ok" | "degraded" | "migration_pending" | "unreachable";
|
|
74
|
+
|
|
75
|
+
/** Map the server's status onto the CLI verdict vocabulary. */
|
|
76
|
+
function toVerdict(status: HealthResponse["status"]): Verdict {
|
|
77
|
+
switch (status) {
|
|
78
|
+
case "healthy":
|
|
79
|
+
return "ok";
|
|
80
|
+
case "degraded":
|
|
81
|
+
return "degraded";
|
|
82
|
+
case "migration_pending":
|
|
83
|
+
return "migration_pending";
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function componentSymbol(status: "up" | "down"): string {
|
|
88
|
+
return status === "up" ? color.green("up") : color.red("down");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function trackLine(name: string, track: HealthTrack): string {
|
|
92
|
+
const sync = track.inSync
|
|
93
|
+
? color.green("in sync")
|
|
94
|
+
: color.yellow(
|
|
95
|
+
`behind (${track.pending.length} pending: ${
|
|
96
|
+
track.pending.length > 0 ? track.pending.join(", ") : "n/a"
|
|
97
|
+
})`,
|
|
98
|
+
);
|
|
99
|
+
const applied = track.applied ?? color.dim("none");
|
|
100
|
+
const required = track.required ?? color.dim("none");
|
|
101
|
+
return `${color.bold(name.padEnd(7))} applied ${applied} -> required ${required} ${sync}`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function run(ctx: CommandContext): Promise<void> {
|
|
105
|
+
const { values } = parseArgs({
|
|
106
|
+
args: ctx.argv,
|
|
107
|
+
allowPositionals: true,
|
|
108
|
+
options: {
|
|
109
|
+
help: { type: "boolean", short: "h", default: false },
|
|
110
|
+
},
|
|
111
|
+
// doctor takes no extra flags of its own; tolerate stray tokens.
|
|
112
|
+
strict: false,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
if (values.help) {
|
|
116
|
+
ctx.out.log(usage);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const { baseUrl } = ctx.http.cfg;
|
|
121
|
+
|
|
122
|
+
// Fetch health. A transport failure (status 0) means unreachable — we surface
|
|
123
|
+
// that as a first-class verdict rather than a hard error so agents get a
|
|
124
|
+
// structured answer. Any other HttpError (non-2xx) is genuinely exceptional.
|
|
125
|
+
let health: HealthResponse | null = null;
|
|
126
|
+
let reachError: string | null = null;
|
|
127
|
+
try {
|
|
128
|
+
health = await ctx.out.step(`GET ${baseUrl}/v1/health`, () =>
|
|
129
|
+
ctx.http.get<HealthResponse>("/v1/health", undefined, { auth: false }),
|
|
130
|
+
);
|
|
131
|
+
} catch (error) {
|
|
132
|
+
if (isHttpError(error) && error.status === 0) {
|
|
133
|
+
reachError = error.message;
|
|
134
|
+
} else if (isHttpError(error)) {
|
|
135
|
+
// A 4xx/5xx from /v1/health: the instance is up but answering badly.
|
|
136
|
+
// Treat as unreachable-for-health so the verdict stays meaningful.
|
|
137
|
+
reachError = error.message;
|
|
138
|
+
} else {
|
|
139
|
+
throw error;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (!health) {
|
|
144
|
+
const verdict: Verdict = "unreachable";
|
|
145
|
+
if (ctx.json) {
|
|
146
|
+
ctx.out.json({
|
|
147
|
+
ok: false,
|
|
148
|
+
verdict,
|
|
149
|
+
baseUrl,
|
|
150
|
+
error: reachError ?? "unreachable",
|
|
151
|
+
});
|
|
152
|
+
process.exit(1);
|
|
153
|
+
}
|
|
154
|
+
ctx.out.note(
|
|
155
|
+
[
|
|
156
|
+
`${color.red("●")} ${color.bold("unreachable")}`,
|
|
157
|
+
"",
|
|
158
|
+
reachError ?? `could not reach ${baseUrl}`,
|
|
159
|
+
"",
|
|
160
|
+
color.dim("Is the instance running? Check --url / HOGSEND_API_URL."),
|
|
161
|
+
].join("\n"),
|
|
162
|
+
"Doctor",
|
|
163
|
+
);
|
|
164
|
+
ctx.out.outro(color.red("doctor: unreachable"));
|
|
165
|
+
process.exit(1);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const verdict = toVerdict(health.status);
|
|
169
|
+
const ok = verdict === "ok";
|
|
170
|
+
|
|
171
|
+
if (ctx.json) {
|
|
172
|
+
ctx.out.json({
|
|
173
|
+
ok,
|
|
174
|
+
verdict,
|
|
175
|
+
baseUrl,
|
|
176
|
+
version: health.version,
|
|
177
|
+
uptime: health.uptime,
|
|
178
|
+
timestamp: health.timestamp,
|
|
179
|
+
components: health.components,
|
|
180
|
+
schema: health.schema,
|
|
181
|
+
skills: skillsStaleness(process.cwd()) ?? undefined,
|
|
182
|
+
});
|
|
183
|
+
if (!ok) process.exit(1);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Human render.
|
|
188
|
+
const badge = `${color.bgMagenta(color.black(" hogsend "))} doctor`;
|
|
189
|
+
ctx.out.intro(badge);
|
|
190
|
+
|
|
191
|
+
const verdictColor =
|
|
192
|
+
verdict === "ok"
|
|
193
|
+
? color.green
|
|
194
|
+
: verdict === "degraded"
|
|
195
|
+
? color.red
|
|
196
|
+
: color.yellow;
|
|
197
|
+
|
|
198
|
+
const lines = [
|
|
199
|
+
`${verdictColor("●")} ${color.bold(verdict)}`,
|
|
200
|
+
color.dim(
|
|
201
|
+
`${baseUrl} v${health.version} up ${Math.round(health.uptime)}s`,
|
|
202
|
+
),
|
|
203
|
+
"",
|
|
204
|
+
color.bold("Components"),
|
|
205
|
+
` database ${componentSymbol(health.components.database.status)}${
|
|
206
|
+
health.components.database.latencyMs !== undefined
|
|
207
|
+
? color.dim(` ${health.components.database.latencyMs}ms`)
|
|
208
|
+
: ""
|
|
209
|
+
}`,
|
|
210
|
+
` redis ${componentSymbol(health.components.redis.status)}${
|
|
211
|
+
health.components.redis.latencyMs !== undefined
|
|
212
|
+
? color.dim(` ${health.components.redis.latencyMs}ms`)
|
|
213
|
+
: ""
|
|
214
|
+
}`,
|
|
215
|
+
"",
|
|
216
|
+
color.bold("Schema"),
|
|
217
|
+
` ${trackLine("engine", health.schema.engine)}`,
|
|
218
|
+
` ${trackLine("client", health.schema.client)}`,
|
|
219
|
+
];
|
|
220
|
+
|
|
221
|
+
ctx.out.note(lines.join("\n"), "Doctor");
|
|
222
|
+
|
|
223
|
+
skillsNudge(ctx);
|
|
224
|
+
|
|
225
|
+
if (ok) {
|
|
226
|
+
ctx.out.outro(color.green("doctor: ok"));
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
ctx.out.outro(verdictColor(`doctor: ${verdict}`));
|
|
231
|
+
process.exit(1);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export const doctorCommand: Command = {
|
|
235
|
+
name: "doctor",
|
|
236
|
+
summary: "Probe a running instance's health (GET /v1/health)",
|
|
237
|
+
usage,
|
|
238
|
+
run,
|
|
239
|
+
};
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { dirname, join, sep } from "node:path";
|
|
4
|
+
import { parseArgs } from "node:util";
|
|
5
|
+
import { EjectError, eject } from "../eject.js";
|
|
6
|
+
import { color } from "../lib/output.js";
|
|
7
|
+
import type { Command, CommandContext } from "./types.js";
|
|
8
|
+
|
|
9
|
+
const usage = `hogsend eject <package> [--force] [--cwd <dir>]
|
|
10
|
+
|
|
11
|
+
Copy a @hogsend/* package's source into vendor/<name> and rewrite the consumer
|
|
12
|
+
dependency to file:./vendor/<name>. Every other dependency keeps upgrading.
|
|
13
|
+
|
|
14
|
+
Options:
|
|
15
|
+
--force Overwrite an existing vendor/<name>.
|
|
16
|
+
--cwd <dir> Consumer repo root (defaults to the current directory).
|
|
17
|
+
-h, --help Show this help.
|
|
18
|
+
|
|
19
|
+
After ejecting, run: pnpm install`;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Resolve the on-disk source directory for an installed package. Strategy 1:
|
|
23
|
+
* probe node_modules/<pkg>/package.json (following pnpm/workspace symlinks).
|
|
24
|
+
* Strategy 2: resolve the package entry via createRequire and walk up.
|
|
25
|
+
*/
|
|
26
|
+
function resolveSourceDir(pkg: string, consumerRoot: string): string | null {
|
|
27
|
+
const direct = join(consumerRoot, "node_modules", pkg, "package.json");
|
|
28
|
+
if (existsSync(direct)) {
|
|
29
|
+
return dirname(realpathSync(direct));
|
|
30
|
+
}
|
|
31
|
+
const require = createRequire(`${consumerRoot}${sep}`);
|
|
32
|
+
try {
|
|
33
|
+
const entry = require.resolve(pkg);
|
|
34
|
+
let dir = dirname(entry);
|
|
35
|
+
while (dir !== dirname(dir)) {
|
|
36
|
+
if (existsSync(join(dir, "package.json"))) return dir;
|
|
37
|
+
dir = dirname(dir);
|
|
38
|
+
}
|
|
39
|
+
} catch {
|
|
40
|
+
// fall through
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function run(ctx: CommandContext): Promise<void> {
|
|
46
|
+
const { values, positionals } = parseArgs({
|
|
47
|
+
args: ctx.argv,
|
|
48
|
+
allowPositionals: true,
|
|
49
|
+
options: {
|
|
50
|
+
force: { type: "boolean", default: false },
|
|
51
|
+
cwd: { type: "string" },
|
|
52
|
+
help: { type: "boolean", short: "h", default: false },
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
if (values.help) {
|
|
57
|
+
ctx.out.log(usage);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const pkg = positionals[0];
|
|
62
|
+
if (!pkg) {
|
|
63
|
+
ctx.out.fail(
|
|
64
|
+
"eject requires a package name, e.g. hogsend eject @hogsend/engine",
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const consumerRoot = values.cwd ?? process.cwd();
|
|
69
|
+
const sourceDir = resolveSourceDir(pkg, consumerRoot);
|
|
70
|
+
if (!sourceDir) {
|
|
71
|
+
ctx.out.fail(
|
|
72
|
+
`cannot resolve ${pkg} from ${consumerRoot}. Is it installed? Run pnpm install first.`,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
const result = await ctx.out.step(`Ejecting ${pkg}`, () =>
|
|
78
|
+
eject({ pkg, consumerRoot, sourceDir, force: values.force }),
|
|
79
|
+
);
|
|
80
|
+
if (ctx.json) {
|
|
81
|
+
ctx.out.json(result);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
ctx.out.note(
|
|
85
|
+
[
|
|
86
|
+
`copied ${result.copiedFiles} files -> ${result.vendorPath}`,
|
|
87
|
+
`dependency ${result.depSpecBefore} -> ${color.cyan(result.depSpecAfter)}`,
|
|
88
|
+
"",
|
|
89
|
+
`Now run: ${color.cyan(result.followUp)}`,
|
|
90
|
+
].join("\n"),
|
|
91
|
+
`Ejected ${result.pkg}`,
|
|
92
|
+
);
|
|
93
|
+
} catch (error) {
|
|
94
|
+
if (error instanceof EjectError) {
|
|
95
|
+
ctx.out.fail(error.message);
|
|
96
|
+
}
|
|
97
|
+
throw error;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export const ejectCommand: Command = {
|
|
102
|
+
name: "eject",
|
|
103
|
+
summary: "Vendor a @hogsend/* package into vendor/<name>",
|
|
104
|
+
usage,
|
|
105
|
+
run,
|
|
106
|
+
};
|
|
@@ -0,0 +1,154 @@
|
|
|
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 events <userId> [options]
|
|
7
|
+
|
|
8
|
+
Stream the event history for a single user, newest first. Wraps
|
|
9
|
+
GET /v1/admin/events?userId=<userId>.
|
|
10
|
+
|
|
11
|
+
Arguments:
|
|
12
|
+
<userId> The user (distinct) id to fetch events for. Required.
|
|
13
|
+
|
|
14
|
+
Options:
|
|
15
|
+
--event <name> Filter to a single event name.
|
|
16
|
+
--from <iso> Only events at/after this ISO-8601 timestamp.
|
|
17
|
+
--to <iso> Only events at/before this ISO-8601 timestamp.
|
|
18
|
+
--limit <n> Max events to return (1-100, default 50).
|
|
19
|
+
--offset <n> Pagination offset (default 0).
|
|
20
|
+
--json Emit machine-readable JSON only.
|
|
21
|
+
-h, --help Show this help.
|
|
22
|
+
|
|
23
|
+
Examples:
|
|
24
|
+
hogsend events user_123
|
|
25
|
+
hogsend events user_123 --event signup --limit 10
|
|
26
|
+
hogsend events user_123 --from 2026-01-01T00:00:00Z --json`;
|
|
27
|
+
|
|
28
|
+
interface UserEvent {
|
|
29
|
+
id: string;
|
|
30
|
+
userId: string;
|
|
31
|
+
event: string;
|
|
32
|
+
properties: Record<string, unknown> | null;
|
|
33
|
+
occurredAt: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface EventsResponse {
|
|
37
|
+
events: UserEvent[];
|
|
38
|
+
total: number;
|
|
39
|
+
limit: number;
|
|
40
|
+
offset: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function run(ctx: CommandContext): Promise<void> {
|
|
44
|
+
const { values, positionals } = parseArgs({
|
|
45
|
+
args: ctx.argv,
|
|
46
|
+
allowPositionals: true,
|
|
47
|
+
options: {
|
|
48
|
+
event: { type: "string" },
|
|
49
|
+
from: { type: "string" },
|
|
50
|
+
to: { type: "string" },
|
|
51
|
+
limit: { type: "string" },
|
|
52
|
+
offset: { type: "string" },
|
|
53
|
+
help: { type: "boolean", short: "h", default: false },
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
if (values.help) {
|
|
58
|
+
ctx.out.log(usage);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const userId = positionals[0];
|
|
63
|
+
if (!userId) {
|
|
64
|
+
ctx.out.fail("events requires a userId, e.g. hogsend events user_123");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const limit = parseNumber(values.limit, "limit", ctx);
|
|
68
|
+
const offset = parseNumber(values.offset, "offset", ctx);
|
|
69
|
+
|
|
70
|
+
const query = {
|
|
71
|
+
userId,
|
|
72
|
+
event: values.event,
|
|
73
|
+
from: values.from,
|
|
74
|
+
to: values.to,
|
|
75
|
+
limit,
|
|
76
|
+
offset,
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
let data: EventsResponse;
|
|
80
|
+
try {
|
|
81
|
+
data = await ctx.out.step(`Fetching events for ${userId}`, () =>
|
|
82
|
+
ctx.http.get<EventsResponse>("/v1/admin/events", query),
|
|
83
|
+
);
|
|
84
|
+
} catch (error) {
|
|
85
|
+
if (isHttpError(error)) {
|
|
86
|
+
ctx.out.fail(error.message);
|
|
87
|
+
}
|
|
88
|
+
throw error;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (ctx.json) {
|
|
92
|
+
ctx.out.json(data);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
ctx.out.intro(`${color.bgMagenta(color.black(" hogsend "))} events`);
|
|
97
|
+
|
|
98
|
+
if (data.events.length === 0) {
|
|
99
|
+
ctx.out.note(
|
|
100
|
+
`No events found for ${color.cyan(userId)}.`,
|
|
101
|
+
"Empty event stream",
|
|
102
|
+
);
|
|
103
|
+
ctx.out.outro(color.dim("Nothing to show."));
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const rows = data.events.map((e) => ({
|
|
108
|
+
occurredAt: e.occurredAt,
|
|
109
|
+
event: e.event,
|
|
110
|
+
properties: summarizeProps(e.properties),
|
|
111
|
+
id: e.id,
|
|
112
|
+
}));
|
|
113
|
+
ctx.out.table(rows, ["occurredAt", "event", "properties", "id"]);
|
|
114
|
+
|
|
115
|
+
const shown = data.events.length;
|
|
116
|
+
const through = data.offset + shown;
|
|
117
|
+
ctx.out.outro(
|
|
118
|
+
`${color.green(String(shown))} event${shown === 1 ? "" : "s"} ` +
|
|
119
|
+
color.dim(`(${data.offset + 1}-${through} of ${data.total})`),
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Parse an optional numeric flag. Returns undefined when absent (lets the
|
|
125
|
+
* server apply its default); fails on a non-numeric value.
|
|
126
|
+
*/
|
|
127
|
+
function parseNumber(
|
|
128
|
+
raw: string | undefined,
|
|
129
|
+
name: string,
|
|
130
|
+
ctx: CommandContext,
|
|
131
|
+
): number | undefined {
|
|
132
|
+
if (raw === undefined) return undefined;
|
|
133
|
+
const n = Number(raw);
|
|
134
|
+
if (!Number.isFinite(n)) {
|
|
135
|
+
ctx.out.fail(`--${name} must be a number, got "${raw}"`);
|
|
136
|
+
}
|
|
137
|
+
return n;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Compact a properties object into a single-line preview for the table. */
|
|
141
|
+
function summarizeProps(props: Record<string, unknown> | null): string {
|
|
142
|
+
if (!props) return "";
|
|
143
|
+
const keys = Object.keys(props);
|
|
144
|
+
if (keys.length === 0) return "";
|
|
145
|
+
const preview = JSON.stringify(props);
|
|
146
|
+
return preview.length > 60 ? `${preview.slice(0, 57)}...` : preview;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export const eventsCommand: Command = {
|
|
150
|
+
name: "events",
|
|
151
|
+
summary: "Stream a single user's event history",
|
|
152
|
+
usage,
|
|
153
|
+
run,
|
|
154
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { contactsCommand } from "./contacts.js";
|
|
2
|
+
import { doctorCommand } from "./doctor.js";
|
|
3
|
+
import { ejectCommand } from "./eject.js";
|
|
4
|
+
import { eventsCommand } from "./events.js";
|
|
5
|
+
import { journeysCommand } from "./journeys.js";
|
|
6
|
+
import { patchCommand } from "./patch.js";
|
|
7
|
+
import { setupCommand } from "./setup.js";
|
|
8
|
+
import { skillsCommand } from "./skills.js";
|
|
9
|
+
import { statsCommand } from "./stats.js";
|
|
10
|
+
import { studioCommand } from "./studio.js";
|
|
11
|
+
import type { Command } from "./types.js";
|
|
12
|
+
import { upgradeCommand } from "./upgrade.js";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The command registry. The router (src/bin.ts) matches the leading argv token
|
|
16
|
+
* against each `command.name` and dispatches to `run()`.
|
|
17
|
+
*
|
|
18
|
+
* Order here is the order shown in root help. Data commands (agent-native,
|
|
19
|
+
* wrapping the engine's /v1/admin/* routes) come first, then the local
|
|
20
|
+
* scaffolding/maintenance commands (setup, skills, eject, patch).
|
|
21
|
+
*/
|
|
22
|
+
export const commands: Command[] = [
|
|
23
|
+
doctorCommand,
|
|
24
|
+
journeysCommand,
|
|
25
|
+
contactsCommand,
|
|
26
|
+
statsCommand,
|
|
27
|
+
eventsCommand,
|
|
28
|
+
studioCommand,
|
|
29
|
+
setupCommand,
|
|
30
|
+
skillsCommand,
|
|
31
|
+
upgradeCommand,
|
|
32
|
+
ejectCommand,
|
|
33
|
+
patchCommand,
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
export type { Command, CommandContext } from "./types.js";
|