@hogsend/cli 0.18.0 → 0.20.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,597 @@
1
+ import type { AdminClient } from "./http.js";
2
+ import { isHttpError } from "./http.js";
3
+ import { LoopbackError, type LoopbackServer } from "./loopback.js";
4
+ import type { DiscoveryResult, TokenResponse } from "./oauth.js";
5
+ import {
6
+ buildAuthorizeUrl,
7
+ generatePkce,
8
+ generateState,
9
+ LOOPBACK_PORTS,
10
+ POSTHOG_CLIENT_ID,
11
+ POSTHOG_SCOPES,
12
+ REQUIRED_ACCESS_LEVEL,
13
+ } from "./oauth.js";
14
+ import type { Output } from "./output.js";
15
+
16
+ /**
17
+ * The testable orchestration behind `hogsend connect posthog`. Every side
18
+ * effect (HTTP, discovery, loopback server, code exchange, browser, prompt,
19
+ * clock) is injected via {@link ConnectFlowDeps}; the command file stays a
20
+ * thin argv/usage wrapper.
21
+ *
22
+ * TOKEN HYGIENE INVARIANT: no access token, refresh token, authorization
23
+ * code, or code verifier is ever passed to any `out.*` call or included in
24
+ * the returned {@link ConnectResult}.
25
+ */
26
+
27
+ /** Mirror of GET /v1/admin/analytics/connect-info (engine analytics route). */
28
+ export interface ConnectInfoResponse {
29
+ providerId: "posthog";
30
+ analyticsConfigured: boolean;
31
+ privateHost: string | null;
32
+ hostExplicit: boolean;
33
+ projectIdHint: string | null;
34
+ personalKeyConfigured: boolean;
35
+ webhookSecretConfigured: boolean;
36
+ apiPublicUrl: string;
37
+ }
38
+
39
+ /** Loose mirror of POST /v1/admin/analytics/provision-loop's 200 (M10: any 2xx is success, no strict parse). */
40
+ interface ProvisionLoopResponse {
41
+ provisioned?: boolean;
42
+ created?: boolean;
43
+ action?: string;
44
+ hogFunctionId?: string;
45
+ webhookUrl?: string;
46
+ dashboardUrl?: string;
47
+ }
48
+
49
+ export type ConnectVerdict =
50
+ | "connected" // credential stored + loop provisioned
51
+ | "connected_no_provision"; // credential stored; provision skipped or failed
52
+
53
+ export type ConnectFailure =
54
+ | "not_configured" // privateHost null (or US-cloud assumption declined)
55
+ | "oauth_unsupported" // discovery 404
56
+ | "discovery_failed"
57
+ | "port_unavailable"
58
+ | "consent_denied"
59
+ | "state_mismatch"
60
+ | "callback_timeout"
61
+ | "exchange_failed"
62
+ | "store_failed"
63
+ | "no_credential" // --provision-only with nothing stored
64
+ | "webhook_secret_missing"
65
+ | "api_public_url_unreachable" // --provision-only without POSTHOG_WEBHOOK_SECRET
66
+ | "provision_failed"; // --provision-only and the POST itself failed
67
+
68
+ export class ConnectError extends Error {
69
+ readonly verdict: ConnectFailure;
70
+ readonly hint?: string;
71
+
72
+ constructor(verdict: ConnectFailure, message: string, hint?: string) {
73
+ super(message);
74
+ this.name = "ConnectError";
75
+ this.verdict = verdict;
76
+ this.hint = hint;
77
+ }
78
+ }
79
+
80
+ export interface ConnectResult {
81
+ verdict: ConnectVerdict;
82
+ providerId: "posthog";
83
+ /** cfg.baseUrl — the Hogsend instance this run targeted. */
84
+ instance: string;
85
+ posthog: {
86
+ privateHost: string;
87
+ issuer?: string;
88
+ scopes: string;
89
+ scopedTeams: number[];
90
+ scopedOrganizations: string[];
91
+ } | null; // null for --provision-only
92
+ credential: { stored: boolean; expiresAt?: string };
93
+ provision:
94
+ | {
95
+ attempted: true;
96
+ ok: true;
97
+ created: boolean;
98
+ hogFunctionId: string;
99
+ webhookUrl: string;
100
+ }
101
+ | { attempted: true; ok: false; error: string }
102
+ | {
103
+ attempted: false;
104
+ skipped:
105
+ | "webhook_secret_missing"
106
+ | "no_provision_flag"
107
+ | "api_public_url_unreachable";
108
+ };
109
+ }
110
+
111
+ export interface ConnectFlowDeps {
112
+ http: AdminClient;
113
+ out: Output;
114
+ /** ctx.out.interactive — gates the US-cloud confirm prompt. */
115
+ interactive: boolean;
116
+ discover: (opts: { privateHost: string }) => Promise<DiscoveryResult>;
117
+ startLoopback: (opts: {
118
+ ports: readonly number[];
119
+ state: string;
120
+ }) => Promise<LoopbackServer>;
121
+ exchangeCode: (opts: {
122
+ tokenEndpoint: string;
123
+ clientId: string;
124
+ code: string;
125
+ codeVerifier: string;
126
+ redirectUri: string;
127
+ }) => Promise<TokenResponse>;
128
+ openBrowser: (url: string) => boolean;
129
+ /** bail-wrapped clack confirm; injected so tests never prompt. */
130
+ confirm: (message: string) => Promise<boolean>;
131
+ now: () => Date;
132
+ }
133
+
134
+ export interface ConnectFlowOptions {
135
+ provisionOnly: boolean;
136
+ noProvision: boolean;
137
+ noBrowser: boolean;
138
+ timeoutMs?: number;
139
+ }
140
+
141
+ // --- §7 UX text — exact strings for failure modes / notes ------------------
142
+
143
+ const HINT_NOT_CONFIGURED =
144
+ "Set POSTHOG_API_KEY (and POSTHOG_HOST for EU/self-hosted) on the " +
145
+ "instance, redeploy, then re-run. The server's PostHog config tells the " +
146
+ "CLI which region to authorize against.";
147
+
148
+ const hintOauthUnsupported = (privateHost: string): string =>
149
+ `${privateHost} doesn't advertise an OAuth server (discovery returned 404).
150
+ Self-hosted PostHog builds may not ship OAuth. Use a personal API key instead:
151
+
152
+ 1. In PostHog: Settings -> User -> Personal API keys -> create a key scoped
153
+ person:read, person:write, project:read, hog_function:write
154
+ 2. Set POSTHOG_PERSONAL_API_KEY=<key> on your Hogsend instance (api + worker)
155
+ 3. Redeploy — person reads and loop provisioning use the key automatically.`;
156
+
157
+ const HINT_PORTS =
158
+ "Ports 8423-8425 on 127.0.0.1 are all in use — free one and re-run. The " +
159
+ "OAuth callback must land on one of these fixed ports; they are " +
160
+ "registered in Hogsend's OAuth client document.";
161
+
162
+ const SSH_NOTE = `The consent page must open in a browser on THIS machine — the OAuth callback
163
+ returns to 127.0.0.1 here. On a remote/SSH session this cannot complete: run
164
+ the command from your laptop instead and point --url at the instance (the CLI
165
+ never needs to run on the server).`;
166
+
167
+ /**
168
+ * Loopback detector — kept in LOCKSTEP with the engine's
169
+ * `isLoopbackPublicUrl` (packages/engine/src/routes/admin/analytics.ts);
170
+ * the CLI has no engine dependency (same reasoning as POSTHOG_CLIENT_ID).
171
+ */
172
+ function isLoopbackUrl(publicUrl: string): boolean {
173
+ try {
174
+ const host = new URL(publicUrl).hostname.toLowerCase();
175
+ return (
176
+ host === "localhost" ||
177
+ host === "127.0.0.1" ||
178
+ host === "0.0.0.0" ||
179
+ host === "[::1]" ||
180
+ host === "::1" ||
181
+ host.endsWith(".localhost")
182
+ );
183
+ } catch {
184
+ return false;
185
+ }
186
+ }
187
+
188
+ const LOOPBACK_URL_NOTE = `Credential stored — but this instance's API_PUBLIC_URL is a loopback
189
+ address, so PostHog Cloud cannot deliver webhooks to it. Provisioning was
190
+ skipped (a destination pointing at localhost would be unreachable).
191
+
192
+ Once deployed, wire the loop against the real instance:
193
+
194
+ hogsend connect posthog --provision-only --url https://your-instance`;
195
+
196
+ const WEBHOOK_SECRET_NOTE = `Credential stored — but the PostHog -> Hogsend event loop needs a shared
197
+ webhook secret, and this instance doesn't have one yet. Finish the loop:
198
+
199
+ 1. Generate a secret: openssl rand -hex 32
200
+ 2. Set it on the instance: POSTHOG_WEBHOOK_SECRET=<secret> (api AND worker)
201
+ 3. Redeploy, then run: hogsend connect posthog --provision-only`;
202
+
203
+ // ---------------------------------------------------------------------------
204
+
205
+ const errMsg = (err: unknown): string =>
206
+ err instanceof Error ? err.message : String(err);
207
+
208
+ const httpErrorBody = (err: unknown): string | undefined => {
209
+ if (!isHttpError(err)) return undefined;
210
+ const body = err.body;
211
+ if (
212
+ body &&
213
+ typeof body === "object" &&
214
+ "error" in body &&
215
+ typeof (body as { error: unknown }).error === "string"
216
+ ) {
217
+ return (body as { error: string }).error;
218
+ }
219
+ return undefined;
220
+ };
221
+
222
+ /** Map a LoopbackError reason onto the ConnectError vocabulary (§5.5 d). */
223
+ function fromLoopbackError(err: LoopbackError): ConnectError {
224
+ switch (err.reason) {
225
+ case "consent_denied":
226
+ return new ConnectError(
227
+ "consent_denied",
228
+ "authorization was denied in PostHog — re-run the command if that " +
229
+ "was a mistake",
230
+ );
231
+ case "state_mismatch":
232
+ return new ConnectError(
233
+ "state_mismatch",
234
+ "state mismatch on the OAuth callback — possible CSRF; retry the " +
235
+ "command",
236
+ );
237
+ case "timeout":
238
+ return new ConnectError(
239
+ "callback_timeout",
240
+ "timed out waiting for the OAuth callback (5 minutes) — re-run " +
241
+ "when you're ready to approve in the browser",
242
+ );
243
+ case "ports_busy":
244
+ return new ConnectError("port_unavailable", err.message, HINT_PORTS);
245
+ case "oauth_error":
246
+ return new ConnectError("exchange_failed", err.message);
247
+ }
248
+ }
249
+
250
+ /** Mandatory provisioning for `--provision-only` (a failure fails the run). */
251
+ async function runProvisionOnly(
252
+ deps: ConnectFlowDeps,
253
+ info: ConnectInfoResponse,
254
+ base: string,
255
+ ): Promise<ConnectResult> {
256
+ if (info.webhookSecretConfigured === false) {
257
+ deps.out.note(WEBHOOK_SECRET_NOTE, "Webhook secret missing");
258
+ throw new ConnectError(
259
+ "webhook_secret_missing",
260
+ "POSTHOG_WEBHOOK_SECRET is not set on the instance — nothing to " +
261
+ "provision against",
262
+ );
263
+ }
264
+
265
+ if (isLoopbackUrl(info.apiPublicUrl)) {
266
+ deps.out.note(LOOPBACK_URL_NOTE, "Instance not publicly reachable");
267
+ throw new ConnectError(
268
+ "api_public_url_unreachable",
269
+ `API_PUBLIC_URL is ${info.apiPublicUrl} — PostHog cannot deliver ` +
270
+ "webhooks to a loopback address",
271
+ );
272
+ }
273
+
274
+ let result: ProvisionLoopResponse;
275
+ try {
276
+ result = await deps.out.step(
277
+ `POST ${base}/v1/admin/analytics/provision-loop`,
278
+ () =>
279
+ deps.http.post<ProvisionLoopResponse>(
280
+ "/v1/admin/analytics/provision-loop",
281
+ {},
282
+ ),
283
+ );
284
+ } catch (err) {
285
+ if (
286
+ isHttpError(err) &&
287
+ err.status === 409 &&
288
+ httpErrorBody(err) === "no_posthog_credential"
289
+ ) {
290
+ throw new ConnectError(
291
+ "no_credential",
292
+ "no PostHog credential is stored on this instance",
293
+ "run `hogsend connect posthog` first",
294
+ );
295
+ }
296
+ throw new ConnectError("provision_failed", errMsg(err));
297
+ }
298
+
299
+ printProvisioned(deps.out, result);
300
+
301
+ return {
302
+ verdict: "connected",
303
+ providerId: "posthog",
304
+ instance: base,
305
+ posthog: null,
306
+ credential: { stored: false },
307
+ provision: {
308
+ attempted: true,
309
+ ok: true,
310
+ created: result.created === true,
311
+ hogFunctionId: result.hogFunctionId ?? "",
312
+ webhookUrl: result.webhookUrl ?? "",
313
+ },
314
+ };
315
+ }
316
+
317
+ function printProvisioned(out: Output, result: ProvisionLoopResponse): void {
318
+ out.note(
319
+ [
320
+ "PostHog -> Hogsend loop provisioned",
321
+ ` webhookUrl ${result.webhookUrl ?? "(unknown)"}`,
322
+ ` hogFunctionId ${result.hogFunctionId ?? "(unknown)"}`,
323
+ ` created ${result.created === true ? "yes" : "no (existing function adopted)"}`,
324
+ ].join("\n"),
325
+ );
326
+ }
327
+
328
+ /**
329
+ * Run the full connect flow (or the `--provision-only` shortcut). Resolves
330
+ * with a {@link ConnectResult} whenever a credential is stored (even if
331
+ * provisioning was skipped or failed); throws {@link ConnectError} otherwise.
332
+ */
333
+ export async function runConnectPosthog(
334
+ deps: ConnectFlowDeps,
335
+ opts: ConnectFlowOptions,
336
+ ): Promise<ConnectResult> {
337
+ const base = deps.http.cfg.baseUrl;
338
+
339
+ // a/b. Ask the server what it knows — the CLI needs no PostHog env vars.
340
+ const info = await deps.out.step(
341
+ `GET ${base}/v1/admin/analytics/connect-info`,
342
+ () =>
343
+ deps.http.get<ConnectInfoResponse>("/v1/admin/analytics/connect-info"),
344
+ );
345
+
346
+ const privateHost = info.privateHost;
347
+ if (privateHost === null) {
348
+ throw new ConnectError(
349
+ "not_configured",
350
+ "this instance has no PostHog configuration",
351
+ HINT_NOT_CONFIGURED,
352
+ );
353
+ }
354
+
355
+ if (opts.provisionOnly) {
356
+ return runProvisionOnly(deps, info, base);
357
+ }
358
+
359
+ if (info.hostExplicit === false) {
360
+ if (deps.interactive) {
361
+ const proceed = await deps.confirm(
362
+ "No POSTHOG_HOST set on the instance — assume PostHog US Cloud " +
363
+ `(${privateHost})?`,
364
+ );
365
+ if (!proceed) {
366
+ throw new ConnectError(
367
+ "not_configured",
368
+ "set POSTHOG_HOST on the instance to pick the right region",
369
+ );
370
+ }
371
+ } else {
372
+ deps.out.log(
373
+ "warning: no POSTHOG_HOST set on the instance — assuming PostHog " +
374
+ `US Cloud (${privateHost}).`,
375
+ );
376
+ }
377
+ }
378
+
379
+ if (info.personalKeyConfigured === true) {
380
+ deps.out.log(
381
+ "note: POSTHOG_PERSONAL_API_KEY is set on the instance; the OAuth " +
382
+ "credential will take precedence once stored.",
383
+ );
384
+ }
385
+
386
+ // c. Discover the region's OAuth server from the instance's private host.
387
+ const metadata = await deps.out.step(
388
+ `OAuth discovery at ${privateHost}`,
389
+ async () => {
390
+ const result = await deps.discover({ privateHost });
391
+ if (result.status === "unsupported") {
392
+ throw new ConnectError(
393
+ "oauth_unsupported",
394
+ `${privateHost} doesn't advertise an OAuth server (discovery ` +
395
+ "returned 404)",
396
+ hintOauthUnsupported(privateHost),
397
+ );
398
+ }
399
+ if (result.status === "error") {
400
+ throw new ConnectError("discovery_failed", result.message);
401
+ }
402
+ return result.metadata;
403
+ },
404
+ );
405
+
406
+ try {
407
+ if (new URL(metadata.issuer).origin !== new URL(privateHost).origin) {
408
+ deps.out.log(
409
+ `warning: discovery issuer ${metadata.issuer} differs from ` +
410
+ `${privateHost} — continuing.`,
411
+ );
412
+ }
413
+ } catch {
414
+ // unparseable issuer — cosmetic check only
415
+ }
416
+
417
+ // d. PKCE + state + loopback receiver + browser consent.
418
+ const pkce = generatePkce();
419
+ const state = generateState();
420
+
421
+ let server: LoopbackServer;
422
+ try {
423
+ server = await deps.startLoopback({ ports: LOOPBACK_PORTS, state });
424
+ } catch (err) {
425
+ if (err instanceof LoopbackError) throw fromLoopbackError(err);
426
+ throw err;
427
+ }
428
+
429
+ let code: string;
430
+ try {
431
+ const authorizeUrl = buildAuthorizeUrl({
432
+ authorizationEndpoint: metadata.authorization_endpoint,
433
+ clientId: POSTHOG_CLIENT_ID,
434
+ redirectUri: server.redirectUri,
435
+ scope: POSTHOG_SCOPES,
436
+ state,
437
+ pkce,
438
+ requiredAccessLevel: REQUIRED_ACCESS_LEVEL,
439
+ });
440
+
441
+ deps.out.note(
442
+ [
443
+ "About to authorize Hogsend against PostHog",
444
+ ` instance ${base}`,
445
+ ` posthog ${privateHost}`,
446
+ ` scopes ${POSTHOG_SCOPES}`,
447
+ ` callback ${server.redirectUri}`,
448
+ ].join("\n"),
449
+ );
450
+
451
+ const opened = opts.noBrowser ? false : deps.openBrowser(authorizeUrl);
452
+ deps.out.log(
453
+ opened
454
+ ? "Opening your browser. If nothing happens, open this URL yourself:"
455
+ : "Open this URL in a browser on THIS machine:",
456
+ );
457
+ deps.out.log(` ${authorizeUrl}`);
458
+ if (!opened) {
459
+ deps.out.note(SSH_NOTE);
460
+ }
461
+
462
+ const callback = await deps.out.step(
463
+ "Waiting for PostHog authorization (Ctrl-C aborts)",
464
+ () => server.waitForCallback({ timeoutMs: opts.timeoutMs }),
465
+ );
466
+ code = callback.code;
467
+ } catch (err) {
468
+ if (err instanceof LoopbackError) throw fromLoopbackError(err);
469
+ throw err;
470
+ } finally {
471
+ await server.close();
472
+ }
473
+
474
+ // e. Exchange the code (public client, PKCE) for tokens.
475
+ const tokenEndpoint = metadata.token_endpoint;
476
+ let tokens: TokenResponse;
477
+ try {
478
+ tokens = await deps.out.step(`Exchanging code at ${tokenEndpoint}`, () =>
479
+ deps.exchangeCode({
480
+ tokenEndpoint,
481
+ clientId: POSTHOG_CLIENT_ID,
482
+ code,
483
+ codeVerifier: pkce.verifier,
484
+ redirectUri: server.redirectUri,
485
+ }),
486
+ );
487
+ } catch (err) {
488
+ throw new ConnectError("exchange_failed", errMsg(err));
489
+ }
490
+
491
+ // f. Store the credential on the instance (canonical payload, SYNTHESIS §0).
492
+ const expiresAt = new Date(
493
+ deps.now().getTime() + tokens.expires_in * 1000,
494
+ ).toISOString();
495
+ const scopes = (tokens.scope ?? POSTHOG_SCOPES).split(" ");
496
+ const scopedTeams = tokens.scoped_teams ?? [];
497
+ const scopedOrganizations = tokens.scoped_organizations ?? [];
498
+
499
+ try {
500
+ await deps.out.step(
501
+ `PUT ${base}/v1/admin/provider-credentials/posthog`,
502
+ () =>
503
+ deps.http.put("/v1/admin/provider-credentials/posthog", {
504
+ kind: "oauth",
505
+ payload: {
506
+ accessToken: tokens.access_token,
507
+ refreshToken: tokens.refresh_token,
508
+ expiresAt,
509
+ tokenEndpoint,
510
+ clientId: POSTHOG_CLIENT_ID,
511
+ scopes,
512
+ scopedTeams,
513
+ scopedOrganizations,
514
+ },
515
+ }),
516
+ );
517
+ } catch (err) {
518
+ throw new ConnectError("store_failed", errMsg(err));
519
+ }
520
+
521
+ const stored: Pick<ConnectResult, "providerId" | "instance" | "posthog"> & {
522
+ credential: ConnectResult["credential"];
523
+ } = {
524
+ providerId: "posthog",
525
+ instance: base,
526
+ posthog: {
527
+ privateHost,
528
+ issuer: metadata.issuer,
529
+ scopes: scopes.join(" "),
530
+ scopedTeams,
531
+ scopedOrganizations,
532
+ },
533
+ credential: { stored: true, expiresAt },
534
+ };
535
+
536
+ // g. Provision the PostHog → Hogsend loop (soft: the credential is stored,
537
+ // so a provisioning failure never fails the command).
538
+ if (opts.noProvision) {
539
+ return {
540
+ verdict: "connected_no_provision",
541
+ ...stored,
542
+ provision: { attempted: false, skipped: "no_provision_flag" },
543
+ };
544
+ }
545
+
546
+ if (info.webhookSecretConfigured === false) {
547
+ deps.out.note(WEBHOOK_SECRET_NOTE, "Webhook secret missing");
548
+ return {
549
+ verdict: "connected_no_provision",
550
+ ...stored,
551
+ provision: { attempted: false, skipped: "webhook_secret_missing" },
552
+ };
553
+ }
554
+
555
+ if (isLoopbackUrl(info.apiPublicUrl)) {
556
+ deps.out.note(LOOPBACK_URL_NOTE, "Instance not publicly reachable");
557
+ return {
558
+ verdict: "connected_no_provision",
559
+ ...stored,
560
+ provision: { attempted: false, skipped: "api_public_url_unreachable" },
561
+ };
562
+ }
563
+
564
+ try {
565
+ const result = await deps.out.step(
566
+ `POST ${base}/v1/admin/analytics/provision-loop`,
567
+ () =>
568
+ deps.http.post<ProvisionLoopResponse>(
569
+ "/v1/admin/analytics/provision-loop",
570
+ {},
571
+ ),
572
+ );
573
+ printProvisioned(deps.out, result);
574
+ return {
575
+ verdict: "connected",
576
+ ...stored,
577
+ provision: {
578
+ attempted: true,
579
+ ok: true,
580
+ created: result.created === true,
581
+ hogFunctionId: result.hogFunctionId ?? "",
582
+ webhookUrl: result.webhookUrl ?? "",
583
+ },
584
+ };
585
+ } catch (err) {
586
+ const message = errMsg(err);
587
+ deps.out.log(
588
+ "The credential is stored, but provisioning the event loop failed: " +
589
+ `${message}. Re-run with: hogsend connect posthog --provision-only`,
590
+ );
591
+ return {
592
+ verdict: "connected_no_provision",
593
+ ...stored,
594
+ provision: { attempted: true, ok: false, error: message },
595
+ };
596
+ }
597
+ }
package/src/lib/http.ts CHANGED
@@ -28,6 +28,7 @@ export interface AdminClient {
28
28
  ): Promise<T>;
29
29
  patch<T = unknown>(path: string, body: unknown): Promise<T>;
30
30
  post<T = unknown>(path: string, body: unknown): Promise<T>;
31
+ put<T = unknown>(path: string, body: unknown): Promise<T>;
31
32
  del<T = unknown>(path: string, body?: unknown): Promise<T>;
32
33
  /** The resolved config this client is bound to (for messages/JSON output). */
33
34
  readonly cfg: ResolvedConfig;
@@ -165,6 +166,11 @@ export function createAdminClient(cfg: ResolvedConfig): AdminClient {
165
166
  body,
166
167
  auth: true,
167
168
  }),
169
+ put: <T>(path: string, body: unknown) =>
170
+ request<T>(cfg.baseUrl, cfg.adminKey, missing, "PUT", path, {
171
+ body,
172
+ auth: true,
173
+ }),
168
174
  del: <T>(path: string, body?: unknown) =>
169
175
  request<T>(cfg.baseUrl, cfg.adminKey, missing, "DELETE", path, {
170
176
  body,