@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,217 @@
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 doctor [--url <baseUrl>] [--admin-key <key>] [--json]
7
+
8
+ Probe a running Hogsend instance via GET /v1/health and report its health:
9
+ component status (database, redis), two-track schema state (engine + client),
10
+ and an overall verdict.
11
+
12
+ The health route is unauthenticated, so doctor works without an admin key.
13
+
14
+ Verdict:
15
+ ok service healthy, all components up, schema in sync
16
+ degraded reachable but a component (database/redis) is down
17
+ migration_pending reachable but a schema track is behind (pending migrations)
18
+ unreachable the instance could not be reached at all
19
+
20
+ Exit code: 0 when ok, 1 when unreachable / degraded / migration_pending.
21
+
22
+ Options:
23
+ --url <baseUrl> Target instance (default HOGSEND_API_URL / .env / :3002).
24
+ --admin-key <key> Unused by doctor (health is unauthenticated).
25
+ --json Emit machine-readable JSON only.
26
+ -h, --help Show this help.`;
27
+
28
+ /** Subset of the engine /v1/health response we render. */
29
+ interface HealthComponent {
30
+ status: "up" | "down";
31
+ latencyMs?: number;
32
+ }
33
+ interface HealthTrack {
34
+ applied: string | null;
35
+ required: string | null;
36
+ inSync: boolean;
37
+ pending: string[];
38
+ }
39
+ interface HealthResponse {
40
+ status: "healthy" | "degraded" | "migration_pending";
41
+ uptime: number;
42
+ timestamp: string;
43
+ version: string;
44
+ components: {
45
+ database: HealthComponent;
46
+ redis: HealthComponent;
47
+ };
48
+ schema: {
49
+ engine: HealthTrack;
50
+ client: HealthTrack;
51
+ };
52
+ }
53
+
54
+ type Verdict = "ok" | "degraded" | "migration_pending" | "unreachable";
55
+
56
+ /** Map the server's status onto the CLI verdict vocabulary. */
57
+ function toVerdict(status: HealthResponse["status"]): Verdict {
58
+ switch (status) {
59
+ case "healthy":
60
+ return "ok";
61
+ case "degraded":
62
+ return "degraded";
63
+ case "migration_pending":
64
+ return "migration_pending";
65
+ }
66
+ }
67
+
68
+ function componentSymbol(status: "up" | "down"): string {
69
+ return status === "up" ? color.green("up") : color.red("down");
70
+ }
71
+
72
+ function trackLine(name: string, track: HealthTrack): string {
73
+ const sync = track.inSync
74
+ ? color.green("in sync")
75
+ : color.yellow(
76
+ `behind (${track.pending.length} pending: ${
77
+ track.pending.length > 0 ? track.pending.join(", ") : "n/a"
78
+ })`,
79
+ );
80
+ const applied = track.applied ?? color.dim("none");
81
+ const required = track.required ?? color.dim("none");
82
+ return `${color.bold(name.padEnd(7))} applied ${applied} -> required ${required} ${sync}`;
83
+ }
84
+
85
+ async function run(ctx: CommandContext): Promise<void> {
86
+ const { values } = parseArgs({
87
+ args: ctx.argv,
88
+ allowPositionals: true,
89
+ options: {
90
+ help: { type: "boolean", short: "h", default: false },
91
+ },
92
+ // doctor takes no extra flags of its own; tolerate stray tokens.
93
+ strict: false,
94
+ });
95
+
96
+ if (values.help) {
97
+ ctx.out.log(usage);
98
+ return;
99
+ }
100
+
101
+ const { baseUrl } = ctx.http.cfg;
102
+
103
+ // Fetch health. A transport failure (status 0) means unreachable — we surface
104
+ // that as a first-class verdict rather than a hard error so agents get a
105
+ // structured answer. Any other HttpError (non-2xx) is genuinely exceptional.
106
+ let health: HealthResponse | null = null;
107
+ let reachError: string | null = null;
108
+ try {
109
+ health = await ctx.out.step(`GET ${baseUrl}/v1/health`, () =>
110
+ ctx.http.get<HealthResponse>("/v1/health", undefined, { auth: false }),
111
+ );
112
+ } catch (error) {
113
+ if (isHttpError(error) && error.status === 0) {
114
+ reachError = error.message;
115
+ } else if (isHttpError(error)) {
116
+ // A 4xx/5xx from /v1/health: the instance is up but answering badly.
117
+ // Treat as unreachable-for-health so the verdict stays meaningful.
118
+ reachError = error.message;
119
+ } else {
120
+ throw error;
121
+ }
122
+ }
123
+
124
+ if (!health) {
125
+ const verdict: Verdict = "unreachable";
126
+ if (ctx.json) {
127
+ ctx.out.json({
128
+ ok: false,
129
+ verdict,
130
+ baseUrl,
131
+ error: reachError ?? "unreachable",
132
+ });
133
+ process.exit(1);
134
+ }
135
+ ctx.out.note(
136
+ [
137
+ `${color.red("●")} ${color.bold("unreachable")}`,
138
+ "",
139
+ reachError ?? `could not reach ${baseUrl}`,
140
+ "",
141
+ color.dim("Is the instance running? Check --url / HOGSEND_API_URL."),
142
+ ].join("\n"),
143
+ "Doctor",
144
+ );
145
+ ctx.out.outro(color.red("doctor: unreachable"));
146
+ process.exit(1);
147
+ }
148
+
149
+ const verdict = toVerdict(health.status);
150
+ const ok = verdict === "ok";
151
+
152
+ if (ctx.json) {
153
+ ctx.out.json({
154
+ ok,
155
+ verdict,
156
+ baseUrl,
157
+ version: health.version,
158
+ uptime: health.uptime,
159
+ timestamp: health.timestamp,
160
+ components: health.components,
161
+ schema: health.schema,
162
+ });
163
+ if (!ok) process.exit(1);
164
+ return;
165
+ }
166
+
167
+ // Human render.
168
+ const badge = `${color.bgMagenta(color.black(" hogsend "))} doctor`;
169
+ ctx.out.intro(badge);
170
+
171
+ const verdictColor =
172
+ verdict === "ok"
173
+ ? color.green
174
+ : verdict === "degraded"
175
+ ? color.red
176
+ : color.yellow;
177
+
178
+ const lines = [
179
+ `${verdictColor("●")} ${color.bold(verdict)}`,
180
+ color.dim(
181
+ `${baseUrl} v${health.version} up ${Math.round(health.uptime)}s`,
182
+ ),
183
+ "",
184
+ color.bold("Components"),
185
+ ` database ${componentSymbol(health.components.database.status)}${
186
+ health.components.database.latencyMs !== undefined
187
+ ? color.dim(` ${health.components.database.latencyMs}ms`)
188
+ : ""
189
+ }`,
190
+ ` redis ${componentSymbol(health.components.redis.status)}${
191
+ health.components.redis.latencyMs !== undefined
192
+ ? color.dim(` ${health.components.redis.latencyMs}ms`)
193
+ : ""
194
+ }`,
195
+ "",
196
+ color.bold("Schema"),
197
+ ` ${trackLine("engine", health.schema.engine)}`,
198
+ ` ${trackLine("client", health.schema.client)}`,
199
+ ];
200
+
201
+ ctx.out.note(lines.join("\n"), "Doctor");
202
+
203
+ if (ok) {
204
+ ctx.out.outro(color.green("doctor: ok"));
205
+ return;
206
+ }
207
+
208
+ ctx.out.outro(verdictColor(`doctor: ${verdict}`));
209
+ process.exit(1);
210
+ }
211
+
212
+ export const doctorCommand: Command = {
213
+ name: "doctor",
214
+ summary: "Probe a running instance's health (GET /v1/health)",
215
+ usage,
216
+ run,
217
+ };
@@ -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,32 @@
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 type { Command } from "./types.js";
11
+
12
+ /**
13
+ * The command registry. The router (src/bin.ts) matches the leading argv token
14
+ * against each `command.name` and dispatches to `run()`.
15
+ *
16
+ * Order here is the order shown in root help. Data commands (agent-native,
17
+ * wrapping the engine's /v1/admin/* routes) come first, then the local
18
+ * scaffolding/maintenance commands (setup, skills, eject, patch).
19
+ */
20
+ export const commands: Command[] = [
21
+ doctorCommand,
22
+ journeysCommand,
23
+ contactsCommand,
24
+ statsCommand,
25
+ eventsCommand,
26
+ setupCommand,
27
+ skillsCommand,
28
+ ejectCommand,
29
+ patchCommand,
30
+ ];
31
+
32
+ export type { Command, CommandContext } from "./types.js";