@hogsend/cli 0.12.2 → 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.
@@ -0,0 +1,268 @@
1
+ /**
2
+ * Headless Hatchet API-token minting against a hatchet-lite instance.
3
+ *
4
+ * Drives hatchet-lite's REST API end to end so HATCHET_CLIENT_TOKEN never has
5
+ * to be copied out of the dashboard by hand:
6
+ *
7
+ * 1. POST /api/v1/users/register (best-effort — falls back to login when
8
+ * the account exists or signups are disabled via SERVER_ALLOW_SIGNUP=false)
9
+ * 2. POST /api/v1/users/login → session cookie
10
+ * 3. GET /api/v1/users/memberships → find the tenant by slug
11
+ * 4. POST /api/v1/tenants → create it if missing (engineVersion V1)
12
+ * 5. POST /api/v1/tenants/{id}/api-tokens → the JWT
13
+ *
14
+ * Endpoint paths + request shapes verified against hatchet-dev/hatchet
15
+ * api-contracts/openapi (UserRegisterRequest, UserLoginRequest,
16
+ * CreateTenantRequest, CreateAPITokenRequest/Response).
17
+ *
18
+ * Pure + injectable (fetch, progress sink) so the flow is unit-testable
19
+ * without a live Hatchet.
20
+ */
21
+
22
+ /** Hatchet's `hatchetName` slug validator (lowercase alnum + dashes). */
23
+ const SLUG_RE = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
24
+
25
+ export interface MintHatchetTokenOptions {
26
+ /** Hatchet base URL, e.g. https://hatchet-lite-production.up.railway.app */
27
+ url: string;
28
+ email: string;
29
+ password: string;
30
+ /** Tenant slug to mint the token in. Default "default" (the seeded tenant). */
31
+ tenantSlug?: string;
32
+ /** Display name for the minted API token. Default "hogsend". */
33
+ tokenName?: string;
34
+ /** Injectable for tests. Defaults to global fetch. */
35
+ fetchImpl?: typeof fetch;
36
+ /** Progress sink (the CLI points this at stderr). Default: silent. */
37
+ onProgress?: (message: string) => void;
38
+ }
39
+
40
+ export interface MintHatchetTokenResult {
41
+ /** The minted Hatchet API token (the HATCHET_CLIENT_TOKEN value). */
42
+ token: string;
43
+ tenantId: string;
44
+ tenantSlug: string;
45
+ /** True when this run created the tenant (vs found an existing membership). */
46
+ createdTenant: boolean;
47
+ /** True when this run registered the user (vs logged into an existing one). */
48
+ registered: boolean;
49
+ }
50
+
51
+ export class HatchetTokenError extends Error {
52
+ readonly status: number | undefined;
53
+ constructor(message: string, status?: number) {
54
+ super(message);
55
+ this.name = "HatchetTokenError";
56
+ this.status = status;
57
+ }
58
+ }
59
+
60
+ /** Hatchet's APIErrors envelope: { errors: [{ description }] }. */
61
+ function extractApiError(body: unknown): string | undefined {
62
+ if (typeof body !== "object" || body === null) return undefined;
63
+ const errors = (body as { errors?: unknown }).errors;
64
+ if (!Array.isArray(errors)) return undefined;
65
+ const descriptions = errors
66
+ .map((e) =>
67
+ typeof e === "object" && e !== null
68
+ ? (e as { description?: unknown }).description
69
+ : undefined,
70
+ )
71
+ .filter((d): d is string => typeof d === "string");
72
+ return descriptions.length > 0 ? descriptions.join("; ") : undefined;
73
+ }
74
+
75
+ async function readBody(res: Response): Promise<unknown> {
76
+ try {
77
+ return await res.json();
78
+ } catch {
79
+ return undefined;
80
+ }
81
+ }
82
+
83
+ /** Build a Cookie header from the response's Set-Cookie headers. */
84
+ function cookieHeaderFrom(res: Response): string {
85
+ const setCookies = res.headers.getSetCookie();
86
+ return setCookies
87
+ .map((c) => c.split(";", 1)[0] ?? "")
88
+ .filter((c) => c.includes("="))
89
+ .join("; ");
90
+ }
91
+
92
+ interface TenantRef {
93
+ id: string;
94
+ slug: string;
95
+ }
96
+
97
+ interface MembershipsResponse {
98
+ rows?: Array<{
99
+ tenant?: {
100
+ metadata?: { id?: string };
101
+ slug?: string;
102
+ };
103
+ }>;
104
+ }
105
+
106
+ /**
107
+ * Register-or-login → ensure tenant → mint an API token. Returns the token;
108
+ * throws {@link HatchetTokenError} with the Hatchet error description on any
109
+ * hard failure (bad credentials, slug taken by another account, etc.).
110
+ */
111
+ export async function mintHatchetToken(
112
+ opts: MintHatchetTokenOptions,
113
+ ): Promise<MintHatchetTokenResult> {
114
+ const fetchImpl = opts.fetchImpl ?? fetch;
115
+ const progress = opts.onProgress ?? (() => {});
116
+ const base = opts.url.replace(/\/+$/, "");
117
+ if (!/^https?:\/\//.test(base)) {
118
+ throw new HatchetTokenError(
119
+ `invalid --url "${opts.url}" (expected http(s)://...)`,
120
+ );
121
+ }
122
+ const tenantSlug = opts.tenantSlug ?? "default";
123
+ if (!SLUG_RE.test(tenantSlug)) {
124
+ throw new HatchetTokenError(
125
+ `invalid tenant slug "${tenantSlug}" (lowercase letters, digits, dashes)`,
126
+ );
127
+ }
128
+ const tokenName = opts.tokenName ?? "hogsend";
129
+
130
+ const postJson = (path: string, body: unknown, cookie?: string) =>
131
+ fetchImpl(`${base}${path}`, {
132
+ method: "POST",
133
+ headers: {
134
+ "content-type": "application/json",
135
+ ...(cookie ? { cookie } : {}),
136
+ },
137
+ body: JSON.stringify(body),
138
+ });
139
+
140
+ // 1. Register (best-effort). 4xx falls through to login — covers both
141
+ // "email is already registered" and a locked-down instance
142
+ // (SERVER_ALLOW_SIGNUP=false → 400, basic auth disabled → 405).
143
+ let registered = false;
144
+ progress(`registering ${opts.email} ...`);
145
+ const registerRes = await postJson("/api/v1/users/register", {
146
+ name: opts.email.split("@")[0] || opts.email,
147
+ email: opts.email,
148
+ password: opts.password,
149
+ });
150
+ if (registerRes.ok) {
151
+ registered = true;
152
+ await readBody(registerRes); // drain
153
+ } else if (registerRes.status >= 500) {
154
+ const msg = extractApiError(await readBody(registerRes));
155
+ throw new HatchetTokenError(
156
+ `Hatchet register failed (${registerRes.status})${msg ? `: ${msg}` : ""}`,
157
+ registerRes.status,
158
+ );
159
+ } else {
160
+ await readBody(registerRes); // drain; fall back to login
161
+ progress("registration unavailable or account exists — logging in ...");
162
+ }
163
+
164
+ // 2. Login → session cookie.
165
+ const loginRes = await postJson("/api/v1/users/login", {
166
+ email: opts.email,
167
+ password: opts.password,
168
+ });
169
+ if (!loginRes.ok) {
170
+ const msg = extractApiError(await readBody(loginRes));
171
+ throw new HatchetTokenError(
172
+ `Hatchet login failed (${loginRes.status})${msg ? `: ${msg}` : ""} — ` +
173
+ "check --email/--password (on a locked-down hatchet-lite these are " +
174
+ "its ADMIN_EMAIL/ADMIN_PASSWORD)",
175
+ loginRes.status,
176
+ );
177
+ }
178
+ await readBody(loginRes); // drain
179
+ const cookie = cookieHeaderFrom(loginRes);
180
+ if (!cookie) {
181
+ throw new HatchetTokenError(
182
+ "Hatchet login succeeded but returned no session cookie",
183
+ );
184
+ }
185
+
186
+ // 3. Find the tenant among the user's memberships.
187
+ progress(`resolving tenant "${tenantSlug}" ...`);
188
+ const membershipsRes = await fetchImpl(`${base}/api/v1/users/memberships`, {
189
+ headers: { cookie },
190
+ });
191
+ if (!membershipsRes.ok) {
192
+ const msg = extractApiError(await readBody(membershipsRes));
193
+ throw new HatchetTokenError(
194
+ `failed to list tenant memberships (${membershipsRes.status})${msg ? `: ${msg}` : ""}`,
195
+ membershipsRes.status,
196
+ );
197
+ }
198
+ const memberships = (await readBody(membershipsRes)) as MembershipsResponse;
199
+ let tenant: TenantRef | undefined;
200
+ for (const row of memberships?.rows ?? []) {
201
+ const id = row.tenant?.metadata?.id;
202
+ if (id && row.tenant?.slug === tenantSlug) {
203
+ tenant = { id, slug: tenantSlug };
204
+ break;
205
+ }
206
+ }
207
+
208
+ // 4. Create the tenant when missing.
209
+ let createdTenant = false;
210
+ if (!tenant) {
211
+ progress(`creating tenant "${tenantSlug}" ...`);
212
+ const createRes = await postJson(
213
+ "/api/v1/tenants",
214
+ { name: tenantSlug, slug: tenantSlug, engineVersion: "V1" },
215
+ cookie,
216
+ );
217
+ const createBody = await readBody(createRes);
218
+ if (!createRes.ok) {
219
+ const msg = extractApiError(createBody);
220
+ throw new HatchetTokenError(
221
+ `failed to create tenant "${tenantSlug}" (${createRes.status})${msg ? `: ${msg}` : ""}`,
222
+ createRes.status,
223
+ );
224
+ }
225
+ const created = createBody as {
226
+ metadata?: { id?: string };
227
+ slug?: string;
228
+ };
229
+ const id = created?.metadata?.id;
230
+ if (!id) {
231
+ throw new HatchetTokenError(
232
+ "tenant create succeeded but the response had no id",
233
+ );
234
+ }
235
+ tenant = { id, slug: tenantSlug };
236
+ createdTenant = true;
237
+ }
238
+
239
+ // 5. Mint the API token.
240
+ progress(`minting API token "${tokenName}" ...`);
241
+ const tokenRes = await postJson(
242
+ `/api/v1/tenants/${tenant.id}/api-tokens`,
243
+ { name: tokenName },
244
+ cookie,
245
+ );
246
+ const tokenBody = await readBody(tokenRes);
247
+ if (!tokenRes.ok) {
248
+ const msg = extractApiError(tokenBody);
249
+ throw new HatchetTokenError(
250
+ `failed to mint API token (${tokenRes.status})${msg ? `: ${msg}` : ""}`,
251
+ tokenRes.status,
252
+ );
253
+ }
254
+ const token = (tokenBody as { token?: unknown })?.token;
255
+ if (typeof token !== "string" || token.length === 0) {
256
+ throw new HatchetTokenError(
257
+ "token create succeeded but the response had no token",
258
+ );
259
+ }
260
+
261
+ return {
262
+ token,
263
+ tenantId: tenant.id,
264
+ tenantSlug: tenant.slug,
265
+ createdTenant,
266
+ registered,
267
+ };
268
+ }