@hogsend/cli 0.20.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 +221 -192
- package/dist/bin.js.map +1 -1
- package/package.json +4 -4
- package/src/__tests__/connect-flow.test.ts +90 -23
- package/src/commands/connect.ts +31 -2
- package/src/lib/connect-flow.ts +89 -45
- package/src/lib/oauth.ts +11 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hogsend/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"tsup": "^8.5.1",
|
|
35
35
|
"tsx": "^4.22.4",
|
|
36
36
|
"vitest": "^4.1.7",
|
|
37
|
-
"@hogsend/studio": "^0.
|
|
37
|
+
"@hogsend/studio": "^0.21.0",
|
|
38
38
|
"@repo/typescript-config": "0.0.0"
|
|
39
39
|
},
|
|
40
40
|
"engines": {
|
|
@@ -44,8 +44,8 @@
|
|
|
44
44
|
"@clack/prompts": "^1.5.0",
|
|
45
45
|
"better-auth": "^1.6.11",
|
|
46
46
|
"picocolors": "^1.1.1",
|
|
47
|
-
"@hogsend/db": "^0.
|
|
48
|
-
"@hogsend/engine": "^0.
|
|
47
|
+
"@hogsend/db": "^0.21.0",
|
|
48
|
+
"@hogsend/engine": "^0.21.0"
|
|
49
49
|
},
|
|
50
50
|
"scripts": {
|
|
51
51
|
"prebuild": "node scripts/bundle-studio.mjs",
|
|
@@ -103,6 +103,8 @@ function makeHarness(opts: {
|
|
|
103
103
|
postResult?: unknown | Error;
|
|
104
104
|
interactive?: boolean;
|
|
105
105
|
confirmAnswer?: boolean;
|
|
106
|
+
/** Injected region resolver for the keyless (privateHost null) path. */
|
|
107
|
+
selectRegion?: () => Promise<string>;
|
|
106
108
|
}): Harness {
|
|
107
109
|
const sink: string[] = [];
|
|
108
110
|
const calls: Harness["calls"] = {
|
|
@@ -209,6 +211,7 @@ function makeHarness(opts: {
|
|
|
209
211
|
return true;
|
|
210
212
|
},
|
|
211
213
|
confirm: async () => opts.confirmAnswer ?? true,
|
|
214
|
+
...(opts.selectRegion ? { selectRegion: opts.selectRegion } : {}),
|
|
212
215
|
now: () => NOW,
|
|
213
216
|
};
|
|
214
217
|
|
|
@@ -253,12 +256,9 @@ describe("runConnectPosthog — happy path", () => {
|
|
|
253
256
|
expiresAt: "2026-06-13T04:00:00.000Z",
|
|
254
257
|
tokenEndpoint: "https://eu.posthog.com/oauth/token/",
|
|
255
258
|
clientId: POSTHOG_CLIENT_ID,
|
|
256
|
-
scopes
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
"project:read",
|
|
260
|
-
"hog_function:write",
|
|
261
|
-
],
|
|
259
|
+
// The granted scopes are the front-loaded set the token response
|
|
260
|
+
// carries (TOKENS.scope === POSTHOG_SCOPES), split on whitespace.
|
|
261
|
+
scopes: POSTHOG_SCOPES.split(" "),
|
|
262
262
|
scopedTeams: [123],
|
|
263
263
|
scopedOrganizations: [],
|
|
264
264
|
},
|
|
@@ -359,23 +359,21 @@ describe("runConnectPosthog — failure verdicts", () => {
|
|
|
359
359
|
});
|
|
360
360
|
|
|
361
361
|
describe("runConnectPosthog — provisioning outcomes", () => {
|
|
362
|
-
it("
|
|
362
|
+
it("proceeds to provision even when the webhook secret is unconfigured (server mints it)", async () => {
|
|
363
|
+
// webhookSecretConfigured:false no longer short-circuits — the server mints
|
|
364
|
+
// + persists the secret during provisioning, so the flow stores the
|
|
365
|
+
// credential AND POSTs provision-loop, landing on `connected`.
|
|
363
366
|
const h = makeHarness({
|
|
364
367
|
info: connectInfo({ webhookSecretConfigured: false }),
|
|
365
368
|
});
|
|
366
369
|
const result = await runConnectPosthog(h.deps, FLOW_DEFAULTS);
|
|
367
370
|
|
|
368
|
-
expect(result.verdict).toBe("
|
|
371
|
+
expect(result.verdict).toBe("connected");
|
|
369
372
|
expect(h.calls.put).toHaveLength(1);
|
|
370
|
-
expect(h.calls.post).
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
});
|
|
375
|
-
|
|
376
|
-
const note = h.sink.find((s) => s.includes("POSTHOG_WEBHOOK_SECRET"));
|
|
377
|
-
expect(note).toBeDefined();
|
|
378
|
-
expect(note).toContain("--provision-only");
|
|
373
|
+
expect(h.calls.post).toEqual([
|
|
374
|
+
{ path: "/v1/admin/analytics/provision-loop", body: {} },
|
|
375
|
+
]);
|
|
376
|
+
expect(result.provision).toMatchObject({ attempted: true, ok: true });
|
|
379
377
|
});
|
|
380
378
|
|
|
381
379
|
it("soft-skips when API_PUBLIC_URL is loopback (PostHog can't reach it)", async () => {
|
|
@@ -466,15 +464,15 @@ describe("runConnectPosthog — --provision-only", () => {
|
|
|
466
464
|
expect(h.calls.post).toHaveLength(0);
|
|
467
465
|
});
|
|
468
466
|
|
|
469
|
-
it("
|
|
467
|
+
it("provisions even when the webhook secret is unconfigured (server mints it)", async () => {
|
|
468
|
+
// --provision-only no longer gates on webhookSecretConfigured — the server
|
|
469
|
+
// mints + persists the secret during provisioning, so the POST proceeds.
|
|
470
470
|
const h = makeHarness({
|
|
471
471
|
info: connectInfo({ webhookSecretConfigured: false }),
|
|
472
472
|
});
|
|
473
|
-
await
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
);
|
|
477
|
-
expect(h.calls.post).toHaveLength(0);
|
|
473
|
+
const result = await runConnectPosthog(h.deps, PROVISION_ONLY);
|
|
474
|
+
expect(result.verdict).toBe("connected");
|
|
475
|
+
expect(h.calls.post).toHaveLength(1);
|
|
478
476
|
});
|
|
479
477
|
|
|
480
478
|
it("any other non-2xx is provision_failed", async () => {
|
|
@@ -490,3 +488,72 @@ describe("runConnectPosthog — --provision-only", () => {
|
|
|
490
488
|
);
|
|
491
489
|
});
|
|
492
490
|
});
|
|
491
|
+
|
|
492
|
+
describe("runConnectPosthog — keyless / region resolution", () => {
|
|
493
|
+
it("resolves the region from --posthog-host on a keyless instance and proceeds", async () => {
|
|
494
|
+
// Server reports no PostHog config (privateHost null) — the CLI no longer
|
|
495
|
+
// hard-fails not_configured; it derives the region from the flag and runs
|
|
496
|
+
// the full OAuth handshake against it.
|
|
497
|
+
const h = makeHarness({ info: connectInfo({ privateHost: null }) });
|
|
498
|
+
const result = await runConnectPosthog(h.deps, {
|
|
499
|
+
...FLOW_DEFAULTS,
|
|
500
|
+
posthogHost: "https://eu.posthog.com/",
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
expect(result.verdict).toBe("connected");
|
|
504
|
+
// Trailing slash stripped; discovery + the OAuth flow ran against it.
|
|
505
|
+
expect(h.calls.discover).toEqual(["https://eu.posthog.com"]);
|
|
506
|
+
expect(result.posthog?.privateHost).toBe("https://eu.posthog.com");
|
|
507
|
+
expect(h.calls.put).toHaveLength(1);
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
it("non-interactive keyless without a flag still fails not_configured", async () => {
|
|
511
|
+
const h = makeHarness({
|
|
512
|
+
info: connectInfo({ privateHost: null }),
|
|
513
|
+
interactive: false,
|
|
514
|
+
});
|
|
515
|
+
await expectConnectError(
|
|
516
|
+
runConnectPosthog(h.deps, FLOW_DEFAULTS),
|
|
517
|
+
"not_configured",
|
|
518
|
+
);
|
|
519
|
+
expect(h.calls.discover).toHaveLength(0);
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
it("interactive keyless uses the injected selectRegion prompt", async () => {
|
|
523
|
+
const selectRegion = async () => "https://us.posthog.com";
|
|
524
|
+
const h = makeHarness({
|
|
525
|
+
info: connectInfo({ privateHost: null }),
|
|
526
|
+
interactive: true,
|
|
527
|
+
selectRegion,
|
|
528
|
+
});
|
|
529
|
+
const result = await runConnectPosthog(h.deps, FLOW_DEFAULTS);
|
|
530
|
+
|
|
531
|
+
expect(result.verdict).toBe("connected");
|
|
532
|
+
expect(h.calls.discover).toEqual(["https://us.posthog.com"]);
|
|
533
|
+
expect(result.posthog?.privateHost).toBe("https://us.posthog.com");
|
|
534
|
+
});
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
describe("runConnectPosthog — scope downscope advisory", () => {
|
|
538
|
+
it("prints a note when PostHog grants fewer scopes than requested", async () => {
|
|
539
|
+
// Simulate a downscope: PostHog grants everything except two read scopes.
|
|
540
|
+
const granted = POSTHOG_SCOPES.split(" ")
|
|
541
|
+
.filter((s) => s !== "cohort:read" && s !== "query:read")
|
|
542
|
+
.join(" ");
|
|
543
|
+
const h = makeHarness({ tokens: { ...TOKENS, scope: granted } });
|
|
544
|
+
const result = await runConnectPosthog(h.deps, FLOW_DEFAULTS);
|
|
545
|
+
|
|
546
|
+
expect(result.verdict).toBe("connected");
|
|
547
|
+
const note = h.sink.find((s) => s.includes("PostHog granted"));
|
|
548
|
+
expect(note).toBeDefined();
|
|
549
|
+
expect(note).toContain("cohort:read");
|
|
550
|
+
expect(note).toContain("query:read");
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
it("prints no note when PostHog grants the full requested set", async () => {
|
|
554
|
+
// Default TOKENS.scope === POSTHOG_SCOPES — a full grant.
|
|
555
|
+
const h = makeHarness({});
|
|
556
|
+
await runConnectPosthog(h.deps, FLOW_DEFAULTS);
|
|
557
|
+
expect(h.sink.find((s) => s.includes("PostHog granted"))).toBeUndefined();
|
|
558
|
+
});
|
|
559
|
+
});
|
package/src/commands/connect.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { parseArgs } from "node:util";
|
|
2
|
-
import { confirm } from "@clack/prompts";
|
|
2
|
+
import { confirm, select, text } from "@clack/prompts";
|
|
3
3
|
import { openBrowser } from "../lib/browser.js";
|
|
4
4
|
import {
|
|
5
5
|
ConnectError,
|
|
@@ -12,7 +12,7 @@ import { color } from "../lib/output.js";
|
|
|
12
12
|
import { bail } from "../lib/prompt.js";
|
|
13
13
|
import type { Command, CommandContext } from "./types.js";
|
|
14
14
|
|
|
15
|
-
const usage = `hogsend connect <provider> [--provision-only] [--no-provision] [--no-browser] [--json]
|
|
15
|
+
const usage = `hogsend connect <provider> [--posthog-host <url>] [--provision-only] [--no-provision] [--no-browser] [--json]
|
|
16
16
|
|
|
17
17
|
Connect this Hogsend instance to an analytics provider via OAuth. Providers:
|
|
18
18
|
|
|
@@ -26,6 +26,10 @@ The browser consent must happen on THIS machine (the OAuth callback lands on
|
|
|
26
26
|
this command from your laptop, not from an SSH session on the server.
|
|
27
27
|
|
|
28
28
|
Options:
|
|
29
|
+
--posthog-host PostHog app/private host to authorize against, e.g.
|
|
30
|
+
https://eu.posthog.com or https://us.posthog.com (NOT the
|
|
31
|
+
i. ingestion host). Required when the instance has no
|
|
32
|
+
PostHog config and you're running non-interactively.
|
|
29
33
|
--provision-only Skip OAuth; (re-)provision the event loop using the
|
|
30
34
|
already-stored credential.
|
|
31
35
|
--no-provision Stop after storing the credential.
|
|
@@ -41,6 +45,7 @@ async function run(ctx: CommandContext): Promise<void> {
|
|
|
41
45
|
allowPositionals: true,
|
|
42
46
|
strict: false,
|
|
43
47
|
options: {
|
|
48
|
+
"posthog-host": { type: "string" },
|
|
44
49
|
"provision-only": { type: "boolean", default: false },
|
|
45
50
|
"no-provision": { type: "boolean", default: false },
|
|
46
51
|
"no-browser": { type: "boolean", default: false },
|
|
@@ -96,6 +101,26 @@ async function run(ctx: CommandContext): Promise<void> {
|
|
|
96
101
|
exchangeCode,
|
|
97
102
|
openBrowser,
|
|
98
103
|
confirm: async (message) => bail(await confirm({ message })),
|
|
104
|
+
selectRegion: async () => {
|
|
105
|
+
const choice = bail(
|
|
106
|
+
await select({
|
|
107
|
+
message: "Which PostHog region should Hogsend authorize against?",
|
|
108
|
+
options: [
|
|
109
|
+
{ value: "https://eu.posthog.com", label: "PostHog EU Cloud" },
|
|
110
|
+
{ value: "https://us.posthog.com", label: "PostHog US Cloud" },
|
|
111
|
+
{ value: "custom", label: "Custom / self-hosted" },
|
|
112
|
+
],
|
|
113
|
+
}),
|
|
114
|
+
) as string;
|
|
115
|
+
if (choice !== "custom") return choice;
|
|
116
|
+
return bail(
|
|
117
|
+
await text({
|
|
118
|
+
message:
|
|
119
|
+
"PostHog app/private host URL (e.g. https://posthog.example.com)",
|
|
120
|
+
placeholder: "https://posthog.example.com",
|
|
121
|
+
}),
|
|
122
|
+
);
|
|
123
|
+
},
|
|
99
124
|
now: () => new Date(),
|
|
100
125
|
};
|
|
101
126
|
|
|
@@ -104,6 +129,10 @@ async function run(ctx: CommandContext): Promise<void> {
|
|
|
104
129
|
provisionOnly: Boolean(values["provision-only"]),
|
|
105
130
|
noProvision: Boolean(values["no-provision"]),
|
|
106
131
|
noBrowser: Boolean(values["no-browser"]),
|
|
132
|
+
posthogHost:
|
|
133
|
+
typeof values["posthog-host"] === "string"
|
|
134
|
+
? values["posthog-host"]
|
|
135
|
+
: undefined,
|
|
107
136
|
});
|
|
108
137
|
|
|
109
138
|
if (ctx.json) {
|
package/src/lib/connect-flow.ts
CHANGED
|
@@ -34,6 +34,8 @@ export interface ConnectInfoResponse {
|
|
|
34
34
|
personalKeyConfigured: boolean;
|
|
35
35
|
webhookSecretConfigured: boolean;
|
|
36
36
|
apiPublicUrl: string;
|
|
37
|
+
/** Expected OAuth scopes missing from the stored credential (advisory). */
|
|
38
|
+
scopeGap?: string[];
|
|
37
39
|
}
|
|
38
40
|
|
|
39
41
|
/** Loose mirror of POST /v1/admin/analytics/provision-loop's 200 (M10: any 2xx is success, no strict parse). */
|
|
@@ -51,7 +53,7 @@ export type ConnectVerdict =
|
|
|
51
53
|
| "connected_no_provision"; // credential stored; provision skipped or failed
|
|
52
54
|
|
|
53
55
|
export type ConnectFailure =
|
|
54
|
-
| "not_configured" // privateHost null
|
|
56
|
+
| "not_configured" // privateHost null and no --posthog-host (non-interactive)
|
|
55
57
|
| "oauth_unsupported" // discovery 404
|
|
56
58
|
| "discovery_failed"
|
|
57
59
|
| "port_unavailable"
|
|
@@ -61,8 +63,7 @@ export type ConnectFailure =
|
|
|
61
63
|
| "exchange_failed"
|
|
62
64
|
| "store_failed"
|
|
63
65
|
| "no_credential" // --provision-only with nothing stored
|
|
64
|
-
| "
|
|
65
|
-
| "api_public_url_unreachable" // --provision-only without POSTHOG_WEBHOOK_SECRET
|
|
66
|
+
| "api_public_url_unreachable" // instance API_PUBLIC_URL is a loopback address
|
|
66
67
|
| "provision_failed"; // --provision-only and the POST itself failed
|
|
67
68
|
|
|
68
69
|
export class ConnectError extends Error {
|
|
@@ -101,10 +102,7 @@ export interface ConnectResult {
|
|
|
101
102
|
| { attempted: true; ok: false; error: string }
|
|
102
103
|
| {
|
|
103
104
|
attempted: false;
|
|
104
|
-
skipped:
|
|
105
|
-
| "webhook_secret_missing"
|
|
106
|
-
| "no_provision_flag"
|
|
107
|
-
| "api_public_url_unreachable";
|
|
105
|
+
skipped: "no_provision_flag" | "api_public_url_unreachable";
|
|
108
106
|
};
|
|
109
107
|
}
|
|
110
108
|
|
|
@@ -128,6 +126,13 @@ export interface ConnectFlowDeps {
|
|
|
128
126
|
openBrowser: (url: string) => boolean;
|
|
129
127
|
/** bail-wrapped clack confirm; injected so tests never prompt. */
|
|
130
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>;
|
|
131
136
|
now: () => Date;
|
|
132
137
|
}
|
|
133
138
|
|
|
@@ -136,14 +141,22 @@ export interface ConnectFlowOptions {
|
|
|
136
141
|
noProvision: boolean;
|
|
137
142
|
noBrowser: boolean;
|
|
138
143
|
timeoutMs?: number;
|
|
144
|
+
/** --posthog-host: the PostHog private/app host to authorize against. */
|
|
145
|
+
posthogHost?: string;
|
|
139
146
|
}
|
|
140
147
|
|
|
141
148
|
// --- §7 UX text — exact strings for failure modes / notes ------------------
|
|
142
149
|
|
|
143
150
|
const HINT_NOT_CONFIGURED =
|
|
144
|
-
"
|
|
145
|
-
"
|
|
146
|
-
"
|
|
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(/\/+$/, "");
|
|
147
160
|
|
|
148
161
|
const hintOauthUnsupported = (privateHost: string): string =>
|
|
149
162
|
`${privateHost} doesn't advertise an OAuth server (discovery returned 404).
|
|
@@ -193,13 +206,6 @@ Once deployed, wire the loop against the real instance:
|
|
|
193
206
|
|
|
194
207
|
hogsend connect posthog --provision-only --url https://your-instance`;
|
|
195
208
|
|
|
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
209
|
// ---------------------------------------------------------------------------
|
|
204
210
|
|
|
205
211
|
const errMsg = (err: unknown): string =>
|
|
@@ -253,15 +259,8 @@ async function runProvisionOnly(
|
|
|
253
259
|
info: ConnectInfoResponse,
|
|
254
260
|
base: string,
|
|
255
261
|
): Promise<ConnectResult> {
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
262
|
+
// The server mints the webhook secret during provisioning, so we no longer
|
|
263
|
+
// gate on webhookSecretConfigured — just proceed to the provision route.
|
|
265
264
|
if (isLoopbackUrl(info.apiPublicUrl)) {
|
|
266
265
|
deps.out.note(LOOPBACK_URL_NOTE, "Instance not publicly reachable");
|
|
267
266
|
throw new ConnectError(
|
|
@@ -325,6 +324,47 @@ function printProvisioned(out: Output, result: ProvisionLoopResponse): void {
|
|
|
325
324
|
);
|
|
326
325
|
}
|
|
327
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
|
+
|
|
328
368
|
/**
|
|
329
369
|
* Run the full connect flow (or the `--provision-only` shortcut). Resolves
|
|
330
370
|
* with a {@link ConnectResult} whenever a credential is stored (even if
|
|
@@ -343,20 +383,19 @@ export async function runConnectPosthog(
|
|
|
343
383
|
deps.http.get<ConnectInfoResponse>("/v1/admin/analytics/connect-info"),
|
|
344
384
|
);
|
|
345
385
|
|
|
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
386
|
if (opts.provisionOnly) {
|
|
356
387
|
return runProvisionOnly(deps, info, base);
|
|
357
388
|
}
|
|
358
389
|
|
|
359
|
-
|
|
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) {
|
|
360
399
|
if (deps.interactive) {
|
|
361
400
|
const proceed = await deps.confirm(
|
|
362
401
|
"No POSTHOG_HOST set on the instance — assume PostHog US Cloud " +
|
|
@@ -533,6 +572,19 @@ export async function runConnectPosthog(
|
|
|
533
572
|
credential: { stored: true, expiresAt },
|
|
534
573
|
};
|
|
535
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
|
+
|
|
536
588
|
// g. Provision the PostHog → Hogsend loop (soft: the credential is stored,
|
|
537
589
|
// so a provisioning failure never fails the command).
|
|
538
590
|
if (opts.noProvision) {
|
|
@@ -543,15 +595,7 @@ export async function runConnectPosthog(
|
|
|
543
595
|
};
|
|
544
596
|
}
|
|
545
597
|
|
|
546
|
-
|
|
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
|
-
|
|
598
|
+
// The server mints the webhook secret during provisioning — no skip here.
|
|
555
599
|
if (isLoopbackUrl(info.apiPublicUrl)) {
|
|
556
600
|
deps.out.note(LOOPBACK_URL_NOTE, "Instance not publicly reachable");
|
|
557
601
|
return {
|
package/src/lib/oauth.ts
CHANGED
|
@@ -24,10 +24,19 @@ export const POSTHOG_CLIENT_ID =
|
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
26
|
* LOCKSTEP (M5): must match the `scope` field of the hosted CIMD document
|
|
27
|
-
* (`apps/docs/public/.well-known/hogsend-posthog-client.json`)
|
|
27
|
+
* (`apps/docs/public/.well-known/hogsend-posthog-client.json`) AND the
|
|
28
|
+
* engine's `EXPECTED_POSTHOG_SCOPES`
|
|
29
|
+
* (`packages/engine/src/lib/posthog-scopes.ts`). Front-loaded beyond the
|
|
30
|
+
* webhook loop's needs (which is only `hog_function:write` + the minted
|
|
31
|
+
* secret) so future read/write features land without forcing a reconnect.
|
|
32
|
+
* Every name here is validated against PostHog's published
|
|
33
|
+
* `scopes_supported`. Grep all three places before changing.
|
|
28
34
|
*/
|
|
29
35
|
export const POSTHOG_SCOPES =
|
|
30
|
-
"person:read person:write project:read
|
|
36
|
+
"person:read person:write project:read organization:read " +
|
|
37
|
+
"hog_function:read hog_function:write feature_flag:read cohort:read " +
|
|
38
|
+
"cohort:write query:read insight:read event_definition:read " +
|
|
39
|
+
"property_definition:read";
|
|
31
40
|
|
|
32
41
|
/**
|
|
33
42
|
* LOCKSTEP (M5): the CIMD document's `redirect_uris` list EXACTLY
|