@hogsend/cli 0.19.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.
- package/dist/bin.js +974 -187
- package/dist/bin.js.map +1 -1
- package/package.json +4 -4
- package/skills/hogsend-deploy/references/env-and-secrets.md +8 -0
- package/src/__tests__/connect-command.test.ts +104 -0
- package/src/__tests__/connect-flow.test.ts +492 -0
- package/src/__tests__/dev.test.ts +1 -0
- package/src/__tests__/domain-command.test.ts +1 -0
- package/src/__tests__/loopback.test.ts +159 -0
- package/src/__tests__/oauth.test.ts +230 -0
- package/src/commands/connect.ts +149 -0
- package/src/commands/index.ts +2 -0
- package/src/commands/studio.ts +1 -16
- package/src/lib/browser.ts +17 -0
- package/src/lib/connect-flow.ts +597 -0
- package/src/lib/http.ts +6 -0
- package/src/lib/loopback.ts +223 -0
- package/src/lib/oauth.ts +256 -0
|
@@ -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,
|