@hogsend/cli 0.12.1 → 0.13.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hogsend/cli",
3
- "version": "0.12.1",
3
+ "version": "0.13.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -32,7 +32,7 @@
32
32
  "tsup": "^8.5.1",
33
33
  "tsx": "^4.22.4",
34
34
  "vitest": "^4.1.7",
35
- "@hogsend/studio": "^0.12.1",
35
+ "@hogsend/studio": "^0.13.0",
36
36
  "@repo/typescript-config": "0.0.0"
37
37
  },
38
38
  "engines": {
@@ -42,8 +42,8 @@
42
42
  "@clack/prompts": "^1.5.0",
43
43
  "better-auth": "^1.6.11",
44
44
  "picocolors": "^1.1.1",
45
- "@hogsend/db": "^0.12.1",
46
- "@hogsend/engine": "^0.12.1"
45
+ "@hogsend/db": "^0.13.0",
46
+ "@hogsend/engine": "^0.13.0"
47
47
  },
48
48
  "scripts": {
49
49
  "prebuild": "node scripts/bundle-studio.mjs",
@@ -31,7 +31,7 @@ export default defineConfig({
31
31
  dbCredentials: {
32
32
  url:
33
33
  process.env.DATABASE_URL ??
34
- "postgresql://growthhog:growthhog@localhost:5434/growthhog",
34
+ "postgresql://hogsend:hogsend@localhost:5434/hogsend",
35
35
  },
36
36
  });
37
37
  ```
@@ -33,6 +33,17 @@ This is the **consumer** deploy guide — for shipping the app you scaffolded wi
33
33
  - **Hatchet-Lite is your orchestration engine.** The worker connects to it over
34
34
  gRPC with `HATCHET_CLIENT_TOKEN` + `HATCHET_CLIENT_HOST_PORT`. Locally it runs
35
35
  via `docker-compose.yml`; in prod it's its own service.
36
+ - **Mint `HATCHET_CLIENT_TOKEN` headlessly.** `hogsend hatchet token
37
+ --url <hatchet-url> --email <e> --password <p> [--tenant <slug>]` drives
38
+ hatchet-lite's REST API (register-or-login → ensure tenant → create token)
39
+ and prints ONLY the token to stdout — pipe it straight into
40
+ `railway variables --set "HATCHET_CLIENT_TOKEN=$(...)"`. No dashboard
41
+ copy-paste needed.
42
+ - **Lock down a public hatchet-lite.** It ships with OPEN registration — anyone
43
+ who finds the public URL can create an account on your Hatchet dashboard. Set
44
+ `SERVER_ALLOW_SIGNUP=false` and seed a real admin via `ADMIN_EMAIL` /
45
+ `ADMIN_PASSWORD` (never leave the `admin@example.com` / `Admin123!!`
46
+ defaults). `hogsend hatchet token` then logs in with those credentials.
36
47
  - **Three required env vars, the rest optional.** `DATABASE_URL`,
37
48
  `BETTER_AUTH_SECRET`, `RESEND_API_KEY` are hard-required at boot; PostHog,
38
49
  webhook secrets, and the admin key are opt-in.
@@ -0,0 +1,205 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { HatchetTokenError, mintHatchetToken } from "../lib/hatchet-token.js";
3
+
4
+ /**
5
+ * Unit tests for the headless HATCHET_CLIENT_TOKEN mint flow
6
+ * (register-or-login → ensure tenant → create API token) against a scripted
7
+ * fake of hatchet-lite's REST API. Covers:
8
+ * - happy path: fresh register → seeded "default" tenant found → token
9
+ * - locked-down instance: register 400 ("user signups are disabled") →
10
+ * login fallback with the seeded admin credentials
11
+ * - tenant missing from memberships → created with engineVersion V1
12
+ * - bad credentials → HatchetTokenError mentioning login
13
+ * - token is read from CreateAPITokenResponse.token verbatim
14
+ */
15
+
16
+ interface Call {
17
+ url: string;
18
+ method: string;
19
+ body?: unknown;
20
+ cookie?: string;
21
+ }
22
+
23
+ type Route = (call: Call) => Response;
24
+
25
+ function fakeHatchet(routes: Record<string, Route>): {
26
+ fetchImpl: typeof fetch;
27
+ calls: Call[];
28
+ } {
29
+ const calls: Call[] = [];
30
+ const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => {
31
+ const url = String(input);
32
+ const path = new URL(url).pathname;
33
+ const headers = new Headers(init?.headers);
34
+ const call: Call = {
35
+ url,
36
+ method: init?.method ?? "GET",
37
+ body: init?.body ? JSON.parse(String(init.body)) : undefined,
38
+ cookie: headers.get("cookie") ?? undefined,
39
+ };
40
+ calls.push(call);
41
+ const route = routes[`${call.method} ${path}`];
42
+ if (!route) {
43
+ return new Response(JSON.stringify({ errors: [] }), { status: 404 });
44
+ }
45
+ return route(call);
46
+ }) as typeof fetch;
47
+ return { fetchImpl, calls };
48
+ }
49
+
50
+ const json = (body: unknown, init?: ResponseInit) =>
51
+ new Response(JSON.stringify(body), {
52
+ status: 200,
53
+ headers: { "content-type": "application/json" },
54
+ ...init,
55
+ });
56
+
57
+ const loginOk = () =>
58
+ json(
59
+ { metadata: { id: "u1" }, email: "a@b.co" },
60
+ { headers: { "set-cookie": "hatchet=sess-abc; Path=/; HttpOnly" } },
61
+ );
62
+
63
+ const membershipsWith = (slug: string, id: string) =>
64
+ json({ rows: [{ tenant: { metadata: { id }, slug } }] });
65
+
66
+ describe("mintHatchetToken", () => {
67
+ it("registers, finds the seeded default tenant, and mints the token", async () => {
68
+ const { fetchImpl, calls } = fakeHatchet({
69
+ "POST /api/v1/users/register": () => json({ metadata: { id: "u1" } }),
70
+ "POST /api/v1/users/login": loginOk,
71
+ "GET /api/v1/users/memberships": () =>
72
+ membershipsWith("default", "tenant-1"),
73
+ "POST /api/v1/tenants/tenant-1/api-tokens": () =>
74
+ json({ token: "jwt-token-123" }),
75
+ });
76
+
77
+ const result = await mintHatchetToken({
78
+ url: "https://hatchet.example.com/",
79
+ email: "admin@acme.com",
80
+ password: "Sup3rSecret!!",
81
+ fetchImpl,
82
+ });
83
+
84
+ expect(result).toEqual({
85
+ token: "jwt-token-123",
86
+ tenantId: "tenant-1",
87
+ tenantSlug: "default",
88
+ createdTenant: false,
89
+ registered: true,
90
+ });
91
+ // Authed calls carry the session cookie from login.
92
+ const tokenCall = calls.find((c) => c.url.includes("api-tokens"));
93
+ expect(tokenCall?.cookie).toBe("hatchet=sess-abc");
94
+ expect(tokenCall?.body).toEqual({ name: "hogsend" });
95
+ });
96
+
97
+ it("falls back to login when signups are disabled (SERVER_ALLOW_SIGNUP=false)", async () => {
98
+ const { fetchImpl } = fakeHatchet({
99
+ "POST /api/v1/users/register": () =>
100
+ json(
101
+ { errors: [{ description: "user signups are disabled" }] },
102
+ { status: 400 },
103
+ ),
104
+ "POST /api/v1/users/login": loginOk,
105
+ "GET /api/v1/users/memberships": () =>
106
+ membershipsWith("default", "tenant-1"),
107
+ "POST /api/v1/tenants/tenant-1/api-tokens": () =>
108
+ json({ token: "jwt-token-456" }),
109
+ });
110
+
111
+ const result = await mintHatchetToken({
112
+ url: "https://hatchet.example.com",
113
+ email: "admin@acme.com",
114
+ password: "Sup3rSecret!!",
115
+ fetchImpl,
116
+ });
117
+
118
+ expect(result.token).toBe("jwt-token-456");
119
+ expect(result.registered).toBe(false);
120
+ });
121
+
122
+ it("creates the tenant (engineVersion V1) when the slug has no membership", async () => {
123
+ const { fetchImpl, calls } = fakeHatchet({
124
+ "POST /api/v1/users/register": () =>
125
+ json({ errors: [] }, { status: 400 }),
126
+ "POST /api/v1/users/login": loginOk,
127
+ "GET /api/v1/users/memberships": () => json({ rows: [] }),
128
+ "POST /api/v1/tenants": () =>
129
+ json({ metadata: { id: "tenant-9" }, slug: "hogsend" }),
130
+ "POST /api/v1/tenants/tenant-9/api-tokens": () =>
131
+ json({ token: "jwt-token-789" }),
132
+ });
133
+
134
+ const result = await mintHatchetToken({
135
+ url: "https://hatchet.example.com",
136
+ email: "admin@acme.com",
137
+ password: "Sup3rSecret!!",
138
+ tenantSlug: "hogsend",
139
+ tokenName: "worker",
140
+ fetchImpl,
141
+ });
142
+
143
+ expect(result).toMatchObject({
144
+ token: "jwt-token-789",
145
+ tenantId: "tenant-9",
146
+ tenantSlug: "hogsend",
147
+ createdTenant: true,
148
+ });
149
+ const createCall = calls.find((c) => c.url.endsWith("/api/v1/tenants"));
150
+ expect(createCall?.body).toEqual({
151
+ name: "hogsend",
152
+ slug: "hogsend",
153
+ engineVersion: "V1",
154
+ });
155
+ expect(createCall?.cookie).toBe("hatchet=sess-abc");
156
+ const tokenCall = calls.find((c) => c.url.includes("api-tokens"));
157
+ expect(tokenCall?.body).toEqual({ name: "worker" });
158
+ });
159
+
160
+ it("throws a login error (with Hatchet's description) on bad credentials", async () => {
161
+ const { fetchImpl } = fakeHatchet({
162
+ "POST /api/v1/users/register": () =>
163
+ json({ errors: [] }, { status: 400 }),
164
+ "POST /api/v1/users/login": () =>
165
+ json(
166
+ { errors: [{ description: "invalid email or password" }] },
167
+ { status: 401 },
168
+ ),
169
+ });
170
+
171
+ await expect(
172
+ mintHatchetToken({
173
+ url: "https://hatchet.example.com",
174
+ email: "admin@acme.com",
175
+ password: "wrong",
176
+ fetchImpl,
177
+ }),
178
+ ).rejects.toThrowError(/login failed \(401\): invalid email or password/);
179
+ });
180
+
181
+ it("rejects an invalid base url and an invalid tenant slug without fetching", async () => {
182
+ const { fetchImpl, calls } = fakeHatchet({});
183
+
184
+ await expect(
185
+ mintHatchetToken({
186
+ url: "hatchet.example.com",
187
+ email: "a@b.co",
188
+ password: "x",
189
+ fetchImpl,
190
+ }),
191
+ ).rejects.toBeInstanceOf(HatchetTokenError);
192
+
193
+ await expect(
194
+ mintHatchetToken({
195
+ url: "https://hatchet.example.com",
196
+ email: "a@b.co",
197
+ password: "x",
198
+ tenantSlug: "Not A Slug",
199
+ fetchImpl,
200
+ }),
201
+ ).rejects.toThrowError(/invalid tenant slug/);
202
+
203
+ expect(calls).toHaveLength(0);
204
+ });
205
+ });
@@ -0,0 +1,181 @@
1
+ import { parseArgs } from "node:util";
2
+ import { HatchetTokenError, mintHatchetToken } from "../lib/hatchet-token.js";
3
+ import { color } from "../lib/output.js";
4
+ import type { Command, CommandContext } from "./types.js";
5
+
6
+ /**
7
+ * `hogsend hatchet token` — mint a HATCHET_CLIENT_TOKEN headlessly against a
8
+ * hatchet-lite instance (register-or-login → ensure tenant → create API token)
9
+ * so the Railway-template "copy the token out of the dashboard" step goes away.
10
+ *
11
+ * Output contract: on success the ONLY thing written to stdout is the token
12
+ * (+ newline) so it pipes cleanly into `railway variables --set` etc. All
13
+ * progress goes to stderr; --json swaps stdout for a single JSON document.
14
+ */
15
+
16
+ const usage = `hogsend hatchet token [options]
17
+
18
+ Mint a Hatchet API token (HATCHET_CLIENT_TOKEN) headlessly against a
19
+ hatchet-lite instance. Registers the account if the instance still allows
20
+ signups, otherwise logs in (e.g. with the seeded ADMIN_EMAIL/ADMIN_PASSWORD on
21
+ a locked-down deployment), ensures the tenant exists, and creates the token.
22
+
23
+ On success the token is the ONLY thing printed to stdout — pipe it straight
24
+ into your platform's variable store. Progress and errors go to stderr.
25
+
26
+ Options:
27
+ --url <hatchet-url> Hatchet base URL (or HATCHET_URL), e.g. the
28
+ hatchet-lite service's public https URL. Required —
29
+ this command never falls back to HOGSEND_API_URL or
30
+ the localhost default (those target your Hogsend
31
+ API, not Hatchet). NOTE: for this command the global
32
+ --url targets HATCHET, not your Hogsend API.
33
+ --email <e> Account email (or HATCHET_ADMIN_EMAIL).
34
+ --password <p> Account password (or HATCHET_ADMIN_PASSWORD). Prefer
35
+ the env var — flags can leak into shell history.
36
+ --tenant <slug> Tenant slug (default "default", the seeded tenant).
37
+ Created (engine V1) if it doesn't exist yet.
38
+ --token-name <n> Display name for the minted token (default "hogsend").
39
+ --json Emit { token, tenantId, ... } as one JSON document.
40
+ -h, --help Show this help.
41
+
42
+ Examples:
43
+ hogsend hatchet token --url https://hatchet-lite-production.up.railway.app \\
44
+ --email admin@example.com --password 'Admin123!!'
45
+ HATCHET_ADMIN_PASSWORD=... hogsend hatchet token --url https://... --email admin@acme.com
46
+ railway variables --service hogsend-worker \\
47
+ --set "HATCHET_CLIENT_TOKEN=$(hogsend hatchet token --url ... --email ... --password ...)"
48
+
49
+ Lockdown note: hatchet-lite ships with OPEN registration — anyone who finds the
50
+ public URL can create an account. On a public deployment set
51
+ SERVER_ALLOW_SIGNUP=false (plus a real ADMIN_EMAIL/ADMIN_PASSWORD, which
52
+ hatchet-lite seeds at boot); this command then logs in with those credentials.`;
53
+
54
+ interface TokenFlags {
55
+ url?: string;
56
+ email?: string;
57
+ password?: string;
58
+ tenant?: string;
59
+ tokenName?: string;
60
+ }
61
+
62
+ function parseTokenFlags(argv: string[]): TokenFlags {
63
+ const { values } = parseArgs({
64
+ args: argv,
65
+ allowPositionals: true,
66
+ strict: false,
67
+ options: {
68
+ url: { type: "string" },
69
+ email: { type: "string" },
70
+ password: { type: "string" },
71
+ tenant: { type: "string" },
72
+ "token-name": { type: "string" },
73
+ },
74
+ });
75
+ const str = (v: unknown): string | undefined =>
76
+ typeof v === "string" && v.length > 0 ? v : undefined;
77
+ return {
78
+ url: str(values.url),
79
+ email: str(values.email),
80
+ password: str(values.password),
81
+ tenant: str(values.tenant),
82
+ tokenName: str(values["token-name"]),
83
+ };
84
+ }
85
+
86
+ /**
87
+ * Terminal failure that honors the stdout-is-only-the-token contract: human
88
+ * mode always writes to stderr (even in a TTY — clack's cancel would land on
89
+ * stdout); --json keeps the single-JSON-document contract via out.fail.
90
+ */
91
+ function failToStderr(ctx: CommandContext, message: string): never {
92
+ if (ctx.json) {
93
+ ctx.out.fail(message);
94
+ }
95
+ process.stderr.write(`${color.red("error")} ${message}\n`);
96
+ process.exit(1);
97
+ }
98
+
99
+ async function runToken(ctx: CommandContext, argv: string[]): Promise<void> {
100
+ const flags = parseTokenFlags(argv);
101
+
102
+ // The router owns the global `--url` and resolves it into cfg.baseUrl before
103
+ // this command sees argv — an explicit `--url <hatchet-url>` arrives as
104
+ // cfg.baseUrl with cfg.urlExplicit set. We deliberately do NOT fall back to
105
+ // cfg.baseUrl otherwise: it ALWAYS resolves (HOGSEND_API_URL env/.env, then
106
+ // localhost:3002), which would silently POST the Hatchet admin credentials
107
+ // to the Hogsend API with a misleading login error. Without an explicit
108
+ // --url, HATCHET_URL is required and the missing-url error below fires. The
109
+ // local flag parse is kept first for safety should the router ever stop
110
+ // owning --url.
111
+ const url =
112
+ flags.url ??
113
+ (ctx.cfg.urlExplicit ? ctx.cfg.baseUrl : undefined) ??
114
+ process.env.HATCHET_URL;
115
+ const email = flags.email ?? process.env.HATCHET_ADMIN_EMAIL;
116
+ const password = flags.password ?? process.env.HATCHET_ADMIN_PASSWORD;
117
+
118
+ const missing: string[] = [];
119
+ if (!url) missing.push("--url (or HATCHET_URL)");
120
+ if (!email) missing.push("--email (or HATCHET_ADMIN_EMAIL)");
121
+ if (!password) missing.push("--password (or HATCHET_ADMIN_PASSWORD)");
122
+ if (missing.length > 0 || !url || !email || !password) {
123
+ failToStderr(ctx, `missing ${missing.join(", ")}`);
124
+ }
125
+
126
+ // Progress goes to stderr ONLY — stdout is reserved for the token.
127
+ const onProgress = ctx.json
128
+ ? undefined
129
+ : (msg: string) => process.stderr.write(`${color.dim(msg)}\n`);
130
+ onProgress?.(`hatchet: ${url}`);
131
+
132
+ try {
133
+ const result = await mintHatchetToken({
134
+ url,
135
+ email,
136
+ password,
137
+ tenantSlug: flags.tenant,
138
+ tokenName: flags.tokenName,
139
+ onProgress,
140
+ });
141
+
142
+ if (ctx.json) {
143
+ ctx.out.json(result);
144
+ return;
145
+ }
146
+ process.stdout.write(`${result.token}\n`);
147
+ } catch (err) {
148
+ if (err instanceof HatchetTokenError) {
149
+ failToStderr(ctx, err.message);
150
+ }
151
+ throw err;
152
+ }
153
+ }
154
+
155
+ async function run(ctx: CommandContext): Promise<void> {
156
+ const sub = ctx.argv[0];
157
+ const rest = ctx.argv.slice(1);
158
+
159
+ if (!sub || sub === "--help" || sub === "-h" || sub === "help") {
160
+ ctx.out.log(usage);
161
+ return;
162
+ }
163
+ if (rest.includes("-h") || rest.includes("--help")) {
164
+ ctx.out.log(usage);
165
+ return;
166
+ }
167
+
168
+ switch (sub) {
169
+ case "token":
170
+ return runToken(ctx, rest);
171
+ default:
172
+ failToStderr(ctx, `unknown subcommand "${sub}" — expected token`);
173
+ }
174
+ }
175
+
176
+ export const hatchetCommand: Command = {
177
+ name: "hatchet",
178
+ summary: "Hatchet helpers — mint a HATCHET_CLIENT_TOKEN headlessly",
179
+ usage,
180
+ run,
181
+ };
@@ -6,6 +6,7 @@ import { domainCommand } from "./domain.js";
6
6
  import { ejectCommand } from "./eject.js";
7
7
  import { emailsCommand } from "./emails.js";
8
8
  import { eventsCommand } from "./events.js";
9
+ import { hatchetCommand } from "./hatchet.js";
9
10
  import { journeysCommand } from "./journeys.js";
10
11
  import { patchCommand } from "./patch.js";
11
12
  import { setupCommand } from "./setup.js";
@@ -34,6 +35,7 @@ export const commands: Command[] = [
34
35
  campaignsCommand,
35
36
  webhooksCommand,
36
37
  domainCommand,
38
+ hatchetCommand,
37
39
  studioCommand,
38
40
  devCommand,
39
41
  setupCommand,
package/src/lib/config.ts CHANGED
@@ -14,6 +14,13 @@ export interface ResolvedConfig {
14
14
  * to the admin key since `full-admin` implies `ingest`.
15
15
  */
16
16
  dataKey: string | undefined;
17
+ /**
18
+ * True when `baseUrl` came from an explicit `--url` flag (vs env / .env /
19
+ * the localhost default). Commands that target a DIFFERENT host than the
20
+ * Hogsend API (e.g. `hatchet token`) must only honor `baseUrl` when this is
21
+ * set, so a cwd `.env`'s HOGSEND_API_URL can't silently become their target.
22
+ */
23
+ urlExplicit: boolean;
17
24
  }
18
25
 
19
26
  /** Global flags parsed off the front of any command's argv. */
@@ -165,5 +172,6 @@ export function resolveConfig(
165
172
  baseUrl: baseUrlRaw.replace(/\/+$/, ""),
166
173
  adminKey: adminKey && adminKey.length > 0 ? adminKey : undefined,
167
174
  dataKey: dataKey && dataKey.length > 0 ? dataKey : undefined,
175
+ urlExplicit: flags.url !== undefined,
168
176
  };
169
177
  }