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