@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hogsend/cli",
3
- "version": "0.18.0",
3
+ "version": "0.20.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.18.0",
37
+ "@hogsend/studio": "^0.20.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.18.0",
48
- "@hogsend/engine": "^0.18.0"
47
+ "@hogsend/db": "^0.20.0",
48
+ "@hogsend/engine": "^0.20.0"
49
49
  },
50
50
  "scripts": {
51
51
  "prebuild": "node scripts/bundle-studio.mjs",
@@ -149,7 +149,10 @@ orchestration:
149
149
  - **`getPostHog()`** — `import { getPostHog } from "@hogsend/engine"` for the raw
150
150
  PostHog service (a fire-and-forget escape hatch). For fanning lifecycle data out
151
151
  to product/data tools, prefer an outbound DESTINATION (see above) — it delivers
152
- durably and is vendor-neutral.
152
+ durably and is vendor-neutral. Note: capture and `$set` person WRITES use the
153
+ `phc_` project key; person READS (`getPersonProperties`) additionally need
154
+ `POSTHOG_PERSONAL_API_KEY` (the project key is write-only by PostHog's design)
155
+ and soft-fail to `{}` without it.
153
156
  - **SMS / push / Slack** — plain functions you import, never on `ctx`.
154
157
  - There is **no `ctx.db`, no `ctx.sendEmail`, no `ctx.hatchet`** surfaced to
155
158
  consumer journeys. If you reach for one of those, you are modelling it wrong —
@@ -73,10 +73,19 @@ dashboard at `http://localhost:8888`, login `admin@example.com` / `Admin123!!`.)
73
73
  All commented-out in `.env.example` — add only what you use:
74
74
 
75
75
  ```bash
76
- # PostHog person properties + event capture (no-op if unset).
76
+ # PostHog event capture + person property WRITES (no-op if unset).
77
77
  POSTHOG_API_KEY=phc_...
78
78
  POSTHOG_HOST=https://us.i.posthog.com
79
79
 
80
+ # PostHog person property READS (timezone resolution, property conditions).
81
+ # The phc_ key is write-only by PostHog's design — reads need a PERSONAL API
82
+ # key scoped person:read. Without it reads soft-fail to contact properties.
83
+ POSTHOG_PERSONAL_API_KEY=...
84
+ # Project id for the private API — discovered automatically when unset.
85
+ # POSTHOG_PROJECT_ID=12345
86
+ # Private API host — derived (eu.i.posthog.com → eu.posthog.com) when unset.
87
+ # POSTHOG_PRIVATE_HOST=https://us.posthog.com
88
+
80
89
  # Verify incoming PostHog webhooks (POST /v1/webhooks/posthog).
81
90
  POSTHOG_WEBHOOK_SECRET=...
82
91
 
@@ -92,6 +101,20 @@ Notes:
92
101
 
93
102
  - **PostHog is fully optional.** Without `POSTHOG_API_KEY`, person-property
94
103
  fetches and event captures are no-ops — journeys still run.
104
+ - **Two PostHog credentials, by PostHog's design.** The `phc_` project key is
105
+ public (it ships in browser bundles) so PostHog makes it WRITE-only: capture
106
+ and `$set` person writes work, reads never will. Person READS (per-user
107
+ timezone resolution) need `POSTHOG_PERSONAL_API_KEY` — a personal API key
108
+ scoped `person:read`, created in PostHog → Settings → Personal API keys.
109
+ `hogsend doctor` warns when capture is configured without it.
110
+ - **Scaffold-time PostHog setup mints `POSTHOG_WEBHOOK_SECRET`.** If the app
111
+ was scaffolded with the PostHog prompt answered (or `--posthog-key`),
112
+ `.env` already carries active `POSTHOG_API_KEY`/`POSTHOG_HOST` values,
113
+ `ENABLE_POSTHOG_DESTINATION=true`, and a randomly minted
114
+ `POSTHOG_WEBHOOK_SECRET`. Copy the first three to BOTH Railway services and
115
+ the webhook secret to the api. Then `hogsend connect posthog` against the
116
+ DEPLOYED instance finishes the loop — it wires person reads and the
117
+ PostHog→Hogsend event loop (the webhook back into `/v1/webhooks/posthog`).
95
118
  - **Webhook secrets are per-source.** Only set the secret for a webhook source
96
119
  you've actually registered (see the consumer's `src/webhook-sources`).
97
120
  - **`ADMIN_API_KEY` gates `/v1/admin/*`.** Set it in prod if you want to drive
@@ -0,0 +1,104 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { connectCommand } from "../commands/connect.js";
3
+ import type { CommandContext } from "../commands/types.js";
4
+ import type { ResolvedConfig } from "../lib/config.js";
5
+ import type { AdminClient } from "../lib/http.js";
6
+ import type { Output } from "../lib/output.js";
7
+
8
+ // Thin argv/usage mapping only — the flow itself is covered by
9
+ // connect-flow.test.ts (stub-CommandContext harness, domain-command style).
10
+
11
+ /** Sentinel thrown by the stubbed `out.fail` instead of process.exit(1). */
12
+ class FailSignal extends Error {
13
+ constructor(readonly failMessage: string) {
14
+ super(failMessage);
15
+ this.name = "FailSignal";
16
+ }
17
+ }
18
+
19
+ function makeCtx(argv: string[]): {
20
+ ctx: CommandContext;
21
+ logs: string[];
22
+ httpCalls: string[];
23
+ } {
24
+ const logs: string[] = [];
25
+ const httpCalls: string[] = [];
26
+
27
+ const out: Output = {
28
+ interactive: false,
29
+ isJson: false,
30
+ intro: () => {},
31
+ step: async <T>(_label: string, fn: () => Promise<T>) => fn(),
32
+ note: (body: string) => {
33
+ logs.push(body);
34
+ },
35
+ table: () => {},
36
+ kv: () => {},
37
+ log: (msg: string) => {
38
+ logs.push(msg);
39
+ },
40
+ json: () => {},
41
+ outro: () => {},
42
+ fail: (message: string): never => {
43
+ throw new FailSignal(message);
44
+ },
45
+ };
46
+
47
+ const cfg = {
48
+ baseUrl: "http://localhost:3002",
49
+ adminKey: "hsk_test",
50
+ dataKey: undefined,
51
+ sources: { baseUrl: "default", adminKey: "flag", dataKey: "default" },
52
+ } as unknown as ResolvedConfig;
53
+
54
+ const reject = (verb: string) => () => {
55
+ httpCalls.push(verb);
56
+ return Promise.reject(new Error(`unexpected ${verb}`));
57
+ };
58
+ const http = {
59
+ cfg,
60
+ get: reject("GET"),
61
+ post: reject("POST"),
62
+ put: reject("PUT"),
63
+ patch: reject("PATCH"),
64
+ del: reject("DELETE"),
65
+ } as unknown as AdminClient;
66
+
67
+ const ctx: CommandContext = {
68
+ argv,
69
+ cfg,
70
+ http,
71
+ dataHttp: {} as CommandContext["dataHttp"],
72
+ out,
73
+ json: false,
74
+ };
75
+
76
+ return { ctx, logs, httpCalls };
77
+ }
78
+
79
+ describe("hogsend connect — argv mapping", () => {
80
+ it("fails when the provider positional is missing", async () => {
81
+ const { ctx } = makeCtx([]);
82
+ await expect(connectCommand.run(ctx)).rejects.toThrow(/missing provider/i);
83
+ });
84
+
85
+ it("fails on an unknown provider", async () => {
86
+ const { ctx } = makeCtx(["stripe"]);
87
+ await expect(connectCommand.run(ctx)).rejects.toThrow(
88
+ /unknown provider "stripe"/,
89
+ );
90
+ });
91
+
92
+ it("rejects --provision-only with --no-provision", async () => {
93
+ const { ctx } = makeCtx(["posthog", "--provision-only", "--no-provision"]);
94
+ await expect(connectCommand.run(ctx)).rejects.toThrow(/mutually exclusive/);
95
+ });
96
+
97
+ it("--help prints usage and makes no HTTP calls", async () => {
98
+ const { ctx, logs, httpCalls } = makeCtx(["posthog", "--help"]);
99
+ await connectCommand.run(ctx);
100
+ expect(logs.join("\n")).toContain("hogsend connect <provider>");
101
+ expect(logs.join("\n")).toContain("--provision-only");
102
+ expect(httpCalls).toHaveLength(0);
103
+ });
104
+ });
@@ -0,0 +1,492 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ ConnectError,
4
+ type ConnectFlowDeps,
5
+ type ConnectFlowOptions,
6
+ type ConnectInfoResponse,
7
+ runConnectPosthog,
8
+ } from "../lib/connect-flow.js";
9
+ import type { AdminClient, HttpError } from "../lib/http.js";
10
+ import { LoopbackError, type LoopbackServer } from "../lib/loopback.js";
11
+ import type { DiscoveryResult, TokenResponse } from "../lib/oauth.js";
12
+ import { POSTHOG_CLIENT_ID, POSTHOG_SCOPES } from "../lib/oauth.js";
13
+ import type { Output } from "../lib/output.js";
14
+
15
+ // Everything is dependency-injected — no vi.mock needed (mirrors the
16
+ // lib-heavy test style of hatchet-token / dns-apply / admin-recovery).
17
+
18
+ const BASE_URL = "https://api.example.com";
19
+ const NOW = new Date("2026-06-12T18:00:00Z");
20
+
21
+ const DISCOVERY_OK: DiscoveryResult = {
22
+ status: "ok",
23
+ metadata: {
24
+ issuer: "https://eu.posthog.com",
25
+ authorization_endpoint: "https://eu.posthog.com/oauth/authorize/",
26
+ token_endpoint: "https://eu.posthog.com/oauth/token/",
27
+ },
28
+ };
29
+
30
+ const TOKENS: TokenResponse = {
31
+ access_token: "pha_fixture_access_secret",
32
+ refresh_token: "phr_fixture_refresh_secret",
33
+ token_type: "Bearer",
34
+ expires_in: 36_000,
35
+ scope: POSTHOG_SCOPES,
36
+ scoped_teams: [123],
37
+ scoped_organizations: [],
38
+ };
39
+
40
+ const PROVISION_OK = {
41
+ provisioned: true,
42
+ created: true,
43
+ action: "created",
44
+ hogFunctionId: "hf-123",
45
+ webhookUrl: `${BASE_URL}/v1/webhooks/posthog`,
46
+ dashboardUrl:
47
+ "https://eu.posthog.com/project/1/pipeline/destinations/hog-hf-123/configuration",
48
+ };
49
+
50
+ function connectInfo(
51
+ over: Partial<ConnectInfoResponse> = {},
52
+ ): ConnectInfoResponse {
53
+ return {
54
+ providerId: "posthog",
55
+ analyticsConfigured: true,
56
+ privateHost: "https://eu.posthog.com",
57
+ hostExplicit: true,
58
+ projectIdHint: null,
59
+ personalKeyConfigured: false,
60
+ webhookSecretConfigured: true,
61
+ apiPublicUrl: BASE_URL,
62
+ ...over,
63
+ };
64
+ }
65
+
66
+ function makeHttpError(status: number, body: unknown): HttpError {
67
+ const err = new Error(`request failed with status ${status}`) as HttpError;
68
+ err.name = "HttpError";
69
+ err.status = status;
70
+ err.body = body;
71
+ return err;
72
+ }
73
+
74
+ interface Harness {
75
+ deps: ConnectFlowDeps;
76
+ /** Every string handed to ANY out.* sink (labels, logs, notes, kv, ...). */
77
+ sink: string[];
78
+ calls: {
79
+ get: string[];
80
+ put: Array<{ path: string; body: unknown }>;
81
+ post: Array<{ path: string; body: unknown }>;
82
+ discover: string[];
83
+ exchange: Array<{
84
+ tokenEndpoint: string;
85
+ clientId: string;
86
+ code: string;
87
+ codeVerifier: string;
88
+ redirectUri: string;
89
+ }>;
90
+ browserUrls: string[];
91
+ loopbackStates: string[];
92
+ };
93
+ server: { closed: boolean };
94
+ }
95
+
96
+ function makeHarness(opts: {
97
+ info?: ConnectInfoResponse;
98
+ discovery?: DiscoveryResult;
99
+ tokens?: TokenResponse | Error;
100
+ waitResult?: { code: string } | LoopbackError;
101
+ startLoopbackError?: LoopbackError;
102
+ putError?: Error;
103
+ postResult?: unknown | Error;
104
+ interactive?: boolean;
105
+ confirmAnswer?: boolean;
106
+ }): Harness {
107
+ const sink: string[] = [];
108
+ const calls: Harness["calls"] = {
109
+ get: [],
110
+ put: [],
111
+ post: [],
112
+ discover: [],
113
+ exchange: [],
114
+ browserUrls: [],
115
+ loopbackStates: [],
116
+ };
117
+ const server = { closed: false };
118
+
119
+ const out: Output = {
120
+ interactive: opts.interactive ?? false,
121
+ isJson: false,
122
+ intro: (title) => {
123
+ sink.push(title);
124
+ },
125
+ step: async (label, fn) => {
126
+ sink.push(label);
127
+ return fn();
128
+ },
129
+ note: (body, title) => {
130
+ sink.push(title ? `${title}\n${body}` : body);
131
+ },
132
+ table: () => {},
133
+ kv: (obj, title) => {
134
+ sink.push(`${title ?? ""}${JSON.stringify(obj)}`);
135
+ },
136
+ log: (msg) => {
137
+ sink.push(msg);
138
+ },
139
+ json: (payload) => {
140
+ sink.push(JSON.stringify(payload));
141
+ },
142
+ outro: (msg) => {
143
+ sink.push(msg);
144
+ },
145
+ fail: (message): never => {
146
+ throw new Error(`fail: ${message}`);
147
+ },
148
+ };
149
+
150
+ const http = {
151
+ cfg: { baseUrl: BASE_URL } as AdminClient["cfg"],
152
+ get: async (path: string) => {
153
+ calls.get.push(path);
154
+ return (opts.info ?? connectInfo()) as never;
155
+ },
156
+ put: async (path: string, body: unknown) => {
157
+ calls.put.push({ path, body });
158
+ if (opts.putError) throw opts.putError;
159
+ return {} as never;
160
+ },
161
+ post: async (path: string, body: unknown) => {
162
+ calls.post.push({ path, body });
163
+ const result = opts.postResult ?? PROVISION_OK;
164
+ if (result instanceof Error) throw result;
165
+ return result as never;
166
+ },
167
+ patch: async () => {
168
+ throw new Error("unexpected PATCH");
169
+ },
170
+ del: async () => {
171
+ throw new Error("unexpected DELETE");
172
+ },
173
+ } as AdminClient;
174
+
175
+ const loopbackServer: LoopbackServer = {
176
+ port: 8423,
177
+ redirectUri: "http://127.0.0.1:8423/callback",
178
+ waitForCallback: async () => {
179
+ const result = opts.waitResult ?? { code: "abc" };
180
+ if (result instanceof LoopbackError) throw result;
181
+ return result;
182
+ },
183
+ close: async () => {
184
+ server.closed = true;
185
+ },
186
+ };
187
+
188
+ const deps: ConnectFlowDeps = {
189
+ http,
190
+ out,
191
+ interactive: opts.interactive ?? false,
192
+ discover: async ({ privateHost }) => {
193
+ calls.discover.push(privateHost);
194
+ return opts.discovery ?? DISCOVERY_OK;
195
+ },
196
+ startLoopback: async ({ state }) => {
197
+ calls.loopbackStates.push(state);
198
+ if (opts.startLoopbackError) throw opts.startLoopbackError;
199
+ return loopbackServer;
200
+ },
201
+ exchangeCode: async (exchangeOpts) => {
202
+ calls.exchange.push(exchangeOpts);
203
+ const tokens = opts.tokens ?? TOKENS;
204
+ if (tokens instanceof Error) throw tokens;
205
+ return tokens;
206
+ },
207
+ openBrowser: (url) => {
208
+ calls.browserUrls.push(url);
209
+ return true;
210
+ },
211
+ confirm: async () => opts.confirmAnswer ?? true,
212
+ now: () => NOW,
213
+ };
214
+
215
+ return { deps, sink, calls, server };
216
+ }
217
+
218
+ const FLOW_DEFAULTS: ConnectFlowOptions = {
219
+ provisionOnly: false,
220
+ noProvision: false,
221
+ noBrowser: false,
222
+ };
223
+
224
+ const expectConnectError = async (
225
+ promise: Promise<unknown>,
226
+ verdict: string,
227
+ ) => {
228
+ await expect(promise).rejects.toSatisfy((err: unknown) => {
229
+ expect(err).toBeInstanceOf(ConnectError);
230
+ expect((err as ConnectError).verdict).toBe(verdict);
231
+ return true;
232
+ });
233
+ };
234
+
235
+ describe("runConnectPosthog — happy path", () => {
236
+ it("stores the canonical credential, provisions, and reports connected", async () => {
237
+ const h = makeHarness({});
238
+ const result = await runConnectPosthog(h.deps, FLOW_DEFAULTS);
239
+
240
+ expect(result.verdict).toBe("connected");
241
+ expect(result.instance).toBe(BASE_URL);
242
+ expect(result.posthog?.scopedTeams).toEqual([123]);
243
+
244
+ // PUT: path + the §3.2 body, exactly.
245
+ expect(h.calls.put).toHaveLength(1);
246
+ expect(h.calls.put[0]?.path).toBe("/v1/admin/provider-credentials/posthog");
247
+ expect(h.calls.put[0]?.body).toEqual({
248
+ kind: "oauth",
249
+ payload: {
250
+ accessToken: "pha_fixture_access_secret",
251
+ refreshToken: "phr_fixture_refresh_secret",
252
+ // now (18:00Z) + 36000 s = 04:00Z next day.
253
+ expiresAt: "2026-06-13T04:00:00.000Z",
254
+ tokenEndpoint: "https://eu.posthog.com/oauth/token/",
255
+ clientId: POSTHOG_CLIENT_ID,
256
+ scopes: [
257
+ "person:read",
258
+ "person:write",
259
+ "project:read",
260
+ "hog_function:write",
261
+ ],
262
+ scopedTeams: [123],
263
+ scopedOrganizations: [],
264
+ },
265
+ });
266
+
267
+ // Provision POST called with {}.
268
+ expect(h.calls.post).toEqual([
269
+ { path: "/v1/admin/analytics/provision-loop", body: {} },
270
+ ]);
271
+
272
+ // The browser URL carries the generated state + PKCE + identity params.
273
+ expect(h.calls.browserUrls).toHaveLength(1);
274
+ const url = new URL(h.calls.browserUrls[0] ?? "");
275
+ expect(url.searchParams.get("state")).toBe(h.calls.loopbackStates[0]);
276
+ expect(url.searchParams.get("code_challenge")).toBeTruthy();
277
+ expect(url.searchParams.get("code_challenge_method")).toBe("S256");
278
+ expect(url.searchParams.get("client_id")).toBe(POSTHOG_CLIENT_ID);
279
+ expect(url.searchParams.get("scope")).toBe(POSTHOG_SCOPES);
280
+
281
+ expect(result.provision).toEqual({
282
+ attempted: true,
283
+ ok: true,
284
+ created: true,
285
+ hogFunctionId: "hf-123",
286
+ webhookUrl: `${BASE_URL}/v1/webhooks/posthog`,
287
+ });
288
+ expect(h.server.closed).toBe(true);
289
+ });
290
+
291
+ it("never leaks tokens, the code, or the verifier to the output sink", async () => {
292
+ const h = makeHarness({});
293
+ await runConnectPosthog(h.deps, FLOW_DEFAULTS);
294
+
295
+ const verifier = h.calls.exchange[0]?.codeVerifier ?? "";
296
+ expect(verifier.length).toBeGreaterThan(0);
297
+
298
+ const all = h.sink.join("\n");
299
+ expect(all).not.toContain("pha_fixture_access_secret");
300
+ expect(all).not.toContain("phr_fixture_refresh_secret");
301
+ expect(all).not.toContain(verifier);
302
+ });
303
+ });
304
+
305
+ describe("runConnectPosthog — failure verdicts", () => {
306
+ it("not_configured when privateHost is null", async () => {
307
+ const h = makeHarness({ info: connectInfo({ privateHost: null }) });
308
+ await expectConnectError(
309
+ runConnectPosthog(h.deps, FLOW_DEFAULTS),
310
+ "not_configured",
311
+ );
312
+ });
313
+
314
+ it("oauth_unsupported on discovery 404 — no loopback, no exchange", async () => {
315
+ const h = makeHarness({ discovery: { status: "unsupported" } });
316
+ await expectConnectError(
317
+ runConnectPosthog(h.deps, FLOW_DEFAULTS),
318
+ "oauth_unsupported",
319
+ );
320
+ expect(h.calls.loopbackStates).toHaveLength(0);
321
+ expect(h.calls.exchange).toHaveLength(0);
322
+ });
323
+
324
+ it("state_mismatch closes the server and never exchanges/stores", async () => {
325
+ const h = makeHarness({
326
+ waitResult: new LoopbackError("state_mismatch", "state mismatch"),
327
+ });
328
+ await expectConnectError(
329
+ runConnectPosthog(h.deps, FLOW_DEFAULTS),
330
+ "state_mismatch",
331
+ );
332
+ expect(h.calls.exchange).toHaveLength(0);
333
+ expect(h.calls.put).toHaveLength(0);
334
+ expect(h.server.closed).toBe(true);
335
+ });
336
+
337
+ it("port_unavailable when all loopback ports are busy", async () => {
338
+ const h = makeHarness({
339
+ startLoopbackError: new LoopbackError("ports_busy", "ports busy"),
340
+ });
341
+ await expectConnectError(
342
+ runConnectPosthog(h.deps, FLOW_DEFAULTS),
343
+ "port_unavailable",
344
+ );
345
+ });
346
+
347
+ it("exchange_failed (missing refresh token) — PUT never called", async () => {
348
+ const h = makeHarness({
349
+ tokens: new Error(
350
+ "token response missing refresh_token — cannot store a long-lived credential",
351
+ ),
352
+ });
353
+ await expectConnectError(
354
+ runConnectPosthog(h.deps, FLOW_DEFAULTS),
355
+ "exchange_failed",
356
+ );
357
+ expect(h.calls.put).toHaveLength(0);
358
+ });
359
+ });
360
+
361
+ describe("runConnectPosthog — provisioning outcomes", () => {
362
+ it("soft-skips when the webhook secret is unconfigured (note with recovery)", async () => {
363
+ const h = makeHarness({
364
+ info: connectInfo({ webhookSecretConfigured: false }),
365
+ });
366
+ const result = await runConnectPosthog(h.deps, FLOW_DEFAULTS);
367
+
368
+ expect(result.verdict).toBe("connected_no_provision");
369
+ expect(h.calls.put).toHaveLength(1);
370
+ expect(h.calls.post).toHaveLength(0);
371
+ expect(result.provision).toEqual({
372
+ attempted: false,
373
+ skipped: "webhook_secret_missing",
374
+ });
375
+
376
+ const note = h.sink.find((s) => s.includes("POSTHOG_WEBHOOK_SECRET"));
377
+ expect(note).toBeDefined();
378
+ expect(note).toContain("--provision-only");
379
+ });
380
+
381
+ it("soft-skips when API_PUBLIC_URL is loopback (PostHog can't reach it)", async () => {
382
+ const h = makeHarness({
383
+ info: connectInfo({ apiPublicUrl: "http://localhost:3002" }),
384
+ });
385
+ const result = await runConnectPosthog(h.deps, FLOW_DEFAULTS);
386
+
387
+ expect(result.verdict).toBe("connected_no_provision");
388
+ expect(h.calls.put).toHaveLength(1);
389
+ expect(h.calls.post).toHaveLength(0);
390
+ expect(result.provision).toEqual({
391
+ attempted: false,
392
+ skipped: "api_public_url_unreachable",
393
+ });
394
+ const note = h.sink.find((s) => s.includes("loopback"));
395
+ expect(note).toBeDefined();
396
+ expect(note).toContain("--provision-only");
397
+ });
398
+
399
+ it("a failed provision POST resolves connected_no_provision (exit stays 0)", async () => {
400
+ const h = makeHarness({
401
+ postResult: makeHttpError(500, { error: "boom" }),
402
+ });
403
+ const result = await runConnectPosthog(h.deps, FLOW_DEFAULTS);
404
+
405
+ expect(result.verdict).toBe("connected_no_provision");
406
+ expect(result.provision).toMatchObject({ attempted: true, ok: false });
407
+ expect(result.credential.stored).toBe(true);
408
+ });
409
+
410
+ it("--no-provision stops after storing the credential", async () => {
411
+ const h = makeHarness({});
412
+ const result = await runConnectPosthog(h.deps, {
413
+ ...FLOW_DEFAULTS,
414
+ noProvision: true,
415
+ });
416
+ expect(result.verdict).toBe("connected_no_provision");
417
+ expect(result.provision).toEqual({
418
+ attempted: false,
419
+ skipped: "no_provision_flag",
420
+ });
421
+ expect(h.calls.post).toHaveLength(0);
422
+ });
423
+ });
424
+
425
+ describe("runConnectPosthog — --provision-only", () => {
426
+ const PROVISION_ONLY: ConnectFlowOptions = {
427
+ ...FLOW_DEFAULTS,
428
+ provisionOnly: true,
429
+ };
430
+
431
+ it("skips OAuth entirely and POSTs once", async () => {
432
+ const h = makeHarness({});
433
+ const result = await runConnectPosthog(h.deps, PROVISION_ONLY);
434
+
435
+ expect(result.verdict).toBe("connected");
436
+ expect(result.posthog).toBeNull();
437
+ expect(h.calls.discover).toHaveLength(0);
438
+ expect(h.calls.loopbackStates).toHaveLength(0);
439
+ expect(h.calls.exchange).toHaveLength(0);
440
+ expect(h.calls.put).toHaveLength(0);
441
+ expect(h.calls.post).toHaveLength(1);
442
+ });
443
+
444
+ it("maps a 409 no_posthog_credential to no_credential with a hint", async () => {
445
+ const h = makeHarness({
446
+ postResult: makeHttpError(409, { error: "no_posthog_credential" }),
447
+ });
448
+ await expect(runConnectPosthog(h.deps, PROVISION_ONLY)).rejects.toSatisfy(
449
+ (err: unknown) => {
450
+ expect(err).toBeInstanceOf(ConnectError);
451
+ expect((err as ConnectError).verdict).toBe("no_credential");
452
+ expect((err as ConnectError).hint).toContain("hogsend connect posthog");
453
+ return true;
454
+ },
455
+ );
456
+ });
457
+
458
+ it("hard-fails api_public_url_unreachable under --provision-only", async () => {
459
+ const h = makeHarness({
460
+ info: connectInfo({ apiPublicUrl: "http://127.0.0.1:3002" }),
461
+ });
462
+ await expectConnectError(
463
+ runConnectPosthog(h.deps, PROVISION_ONLY),
464
+ "api_public_url_unreachable",
465
+ );
466
+ expect(h.calls.post).toHaveLength(0);
467
+ });
468
+
469
+ it("hard-fails webhook_secret_missing BEFORE calling the route", async () => {
470
+ const h = makeHarness({
471
+ info: connectInfo({ webhookSecretConfigured: false }),
472
+ });
473
+ await expectConnectError(
474
+ runConnectPosthog(h.deps, PROVISION_ONLY),
475
+ "webhook_secret_missing",
476
+ );
477
+ expect(h.calls.post).toHaveLength(0);
478
+ });
479
+
480
+ it("any other non-2xx is provision_failed", async () => {
481
+ const h = makeHarness({
482
+ postResult: makeHttpError(502, {
483
+ error: "missing-scope",
484
+ detail: "denied",
485
+ }),
486
+ });
487
+ await expectConnectError(
488
+ runConnectPosthog(h.deps, PROVISION_ONLY),
489
+ "provision_failed",
490
+ );
491
+ });
492
+ });
@@ -79,6 +79,7 @@ function makeCtx(opts: {
79
79
  ),
80
80
  post: () => Promise.reject(new Error("unexpected POST")),
81
81
  patch: () => Promise.reject(new Error("unexpected PATCH")),
82
+ put: () => Promise.reject(new Error("unexpected PUT")),
82
83
  del: () => Promise.reject(new Error("unexpected DELETE")),
83
84
  } as AdminClient;
84
85
 
@@ -111,6 +111,7 @@ function makeCtx(opts: {
111
111
  body,
112
112
  ),
113
113
  patch: () => Promise.reject(new Error("unexpected PATCH")),
114
+ put: () => Promise.reject(new Error("unexpected PUT")),
114
115
  del: () => Promise.reject(new Error("unexpected DELETE")),
115
116
  } as AdminClient;
116
117