@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/dist/bin.js +994 -187
- package/dist/bin.js.map +1 -1
- package/package.json +4 -4
- package/skills/hogsend-authoring-journeys/references/journey-context.md +4 -1
- package/skills/hogsend-deploy/references/env-and-secrets.md +24 -1
- 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/doctor.ts +30 -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,159 @@
|
|
|
1
|
+
import { createServer as createNetServer, type Server } from "node:net";
|
|
2
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
3
|
+
import {
|
|
4
|
+
LoopbackError,
|
|
5
|
+
type LoopbackServer,
|
|
6
|
+
startLoopbackServer,
|
|
7
|
+
} from "../lib/loopback.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Real node:http servers throughout — production passes LOOPBACK_PORTS, but
|
|
11
|
+
* tests inject `ports: [0]` for ephemeral binds so CI never collides.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const STATE = "S";
|
|
15
|
+
const loopbacks: LoopbackServer[] = [];
|
|
16
|
+
const blockers: Server[] = [];
|
|
17
|
+
|
|
18
|
+
afterEach(async () => {
|
|
19
|
+
for (const server of loopbacks.splice(0)) {
|
|
20
|
+
await server.close();
|
|
21
|
+
}
|
|
22
|
+
for (const server of blockers.splice(0)) {
|
|
23
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
async function start(ports: readonly number[] = [0], state = STATE) {
|
|
28
|
+
const server = await startLoopbackServer({ ports, state });
|
|
29
|
+
loopbacks.push(server);
|
|
30
|
+
return server;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Bind a throwaway TCP server to occupy an ephemeral port. */
|
|
34
|
+
function occupyPort(): Promise<{ port: number; server: Server }> {
|
|
35
|
+
return new Promise((resolve) => {
|
|
36
|
+
const server = createNetServer();
|
|
37
|
+
server.listen({ host: "127.0.0.1", port: 0 }, () => {
|
|
38
|
+
const address = server.address();
|
|
39
|
+
const port =
|
|
40
|
+
address !== null && typeof address === "object" ? address.port : 0;
|
|
41
|
+
blockers.push(server);
|
|
42
|
+
resolve({ port, server });
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const expectLoopbackRejection = async (
|
|
48
|
+
promise: Promise<unknown>,
|
|
49
|
+
reason: string,
|
|
50
|
+
) => {
|
|
51
|
+
await expect(promise).rejects.toSatisfy((err: unknown) => {
|
|
52
|
+
expect(err).toBeInstanceOf(LoopbackError);
|
|
53
|
+
expect((err as LoopbackError).reason).toBe(reason);
|
|
54
|
+
return true;
|
|
55
|
+
});
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
describe("startLoopbackServer", () => {
|
|
59
|
+
it("happy path: success page + resolved code", async () => {
|
|
60
|
+
const server = await start();
|
|
61
|
+
const wait = server.waitForCallback();
|
|
62
|
+
|
|
63
|
+
const res = await fetch(`${server.redirectUri}?code=abc&state=${STATE}`);
|
|
64
|
+
expect(res.status).toBe(200);
|
|
65
|
+
expect(await res.text()).toContain("Connected");
|
|
66
|
+
|
|
67
|
+
await expect(wait).resolves.toEqual({ code: "abc" });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("rejects state_mismatch with HTTP 400", async () => {
|
|
71
|
+
const server = await start();
|
|
72
|
+
// Attach the rejection handler BEFORE triggering the callback, so the
|
|
73
|
+
// rejection is never momentarily unhandled.
|
|
74
|
+
const assertion = expectLoopbackRejection(
|
|
75
|
+
server.waitForCallback(),
|
|
76
|
+
"state_mismatch",
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
const res = await fetch(`${server.redirectUri}?code=abc&state=WRONG`);
|
|
80
|
+
expect(res.status).toBe(400);
|
|
81
|
+
|
|
82
|
+
await assertion;
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("rejects consent_denied on error=access_denied", async () => {
|
|
86
|
+
const server = await start();
|
|
87
|
+
const assertion = expectLoopbackRejection(
|
|
88
|
+
server.waitForCallback(),
|
|
89
|
+
"consent_denied",
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
const res = await fetch(
|
|
93
|
+
`${server.redirectUri}?error=access_denied&state=${STATE}`,
|
|
94
|
+
);
|
|
95
|
+
expect(res.status).toBe(200);
|
|
96
|
+
|
|
97
|
+
await assertion;
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("404s wrong paths and keeps the wait pending", async () => {
|
|
101
|
+
const server = await start();
|
|
102
|
+
const wait = server.waitForCallback();
|
|
103
|
+
|
|
104
|
+
const res = await fetch(`http://127.0.0.1:${server.port}/nope`);
|
|
105
|
+
expect(res.status).toBe(404);
|
|
106
|
+
|
|
107
|
+
// Still pending: a sentinel wins the race.
|
|
108
|
+
const pending = await Promise.race([
|
|
109
|
+
wait.then(() => "settled"),
|
|
110
|
+
new Promise((resolve) => setTimeout(() => resolve("pending"), 50)),
|
|
111
|
+
]);
|
|
112
|
+
expect(pending).toBe("pending");
|
|
113
|
+
|
|
114
|
+
// Settle it for cleanup symmetry.
|
|
115
|
+
await fetch(`${server.redirectUri}?code=abc&state=${STATE}`);
|
|
116
|
+
await expect(wait).resolves.toEqual({ code: "abc" });
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("falls back to the next port when the first is busy", async () => {
|
|
120
|
+
const { port } = await occupyPort();
|
|
121
|
+
const server = await start([port, 0]);
|
|
122
|
+
expect(server.port).not.toBe(port);
|
|
123
|
+
expect(server.port).toBeGreaterThan(0);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("rejects ports_busy listing the ports when all are taken", async () => {
|
|
127
|
+
const { port } = await occupyPort();
|
|
128
|
+
await expect(
|
|
129
|
+
startLoopbackServer({ ports: [port], state: STATE }),
|
|
130
|
+
).rejects.toSatisfy((err: unknown) => {
|
|
131
|
+
expect(err).toBeInstanceOf(LoopbackError);
|
|
132
|
+
expect((err as LoopbackError).reason).toBe("ports_busy");
|
|
133
|
+
expect((err as LoopbackError).message).toContain(String(port));
|
|
134
|
+
return true;
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("times out when no callback arrives", async () => {
|
|
139
|
+
const server = await start();
|
|
140
|
+
await expectLoopbackRejection(
|
|
141
|
+
server.waitForCallback({ timeoutMs: 20 }),
|
|
142
|
+
"timeout",
|
|
143
|
+
);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("first answer wins — later requests get a 410", async () => {
|
|
147
|
+
const server = await start();
|
|
148
|
+
const wait = server.waitForCallback();
|
|
149
|
+
|
|
150
|
+
const first = await fetch(`${server.redirectUri}?code=abc&state=${STATE}`);
|
|
151
|
+
expect(first.status).toBe(200);
|
|
152
|
+
await expect(wait).resolves.toEqual({ code: "abc" });
|
|
153
|
+
|
|
154
|
+
const second = await fetch(
|
|
155
|
+
`${server.redirectUri}?code=later&state=${STATE}`,
|
|
156
|
+
);
|
|
157
|
+
expect(second.status).toBe(410);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
buildAuthorizeUrl,
|
|
4
|
+
computeChallenge,
|
|
5
|
+
discoverOAuthServer,
|
|
6
|
+
exchangeCode,
|
|
7
|
+
generatePkce,
|
|
8
|
+
generateState,
|
|
9
|
+
POSTHOG_CLIENT_ID,
|
|
10
|
+
POSTHOG_SCOPES,
|
|
11
|
+
} from "../lib/oauth.js";
|
|
12
|
+
|
|
13
|
+
const BASE64URL_RE = /^[A-Za-z0-9_-]+$/;
|
|
14
|
+
|
|
15
|
+
/** Minimal recording fetch fake (mirrors hatchet-token.test.ts's pattern). */
|
|
16
|
+
function fakeFetch(
|
|
17
|
+
respond: (url: string, init?: RequestInit) => Response | Promise<Response>,
|
|
18
|
+
): { fetchImpl: typeof fetch; calls: { url: string; init?: RequestInit }[] } {
|
|
19
|
+
const calls: { url: string; init?: RequestInit }[] = [];
|
|
20
|
+
const fetchImpl = (async (input: unknown, init?: RequestInit) => {
|
|
21
|
+
const url = String(input);
|
|
22
|
+
calls.push({ url, init });
|
|
23
|
+
return respond(url, init);
|
|
24
|
+
}) as typeof fetch;
|
|
25
|
+
return { fetchImpl, calls };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const json = (body: unknown, status = 200) =>
|
|
29
|
+
new Response(JSON.stringify(body), {
|
|
30
|
+
status,
|
|
31
|
+
headers: { "content-type": "application/json" },
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe("computeChallenge", () => {
|
|
35
|
+
it("matches the RFC 7636 appendix B vector", () => {
|
|
36
|
+
expect(
|
|
37
|
+
computeChallenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"),
|
|
38
|
+
).toBe("E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM");
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("generatePkce", () => {
|
|
43
|
+
it("emits a 43-char base64url verifier with a matching S256 challenge", () => {
|
|
44
|
+
const pkce = generatePkce();
|
|
45
|
+
expect(pkce.verifier).toHaveLength(43);
|
|
46
|
+
expect(pkce.verifier).toMatch(BASE64URL_RE);
|
|
47
|
+
expect(pkce.verifier).not.toContain("=");
|
|
48
|
+
expect(pkce.challenge).toBe(computeChallenge(pkce.verifier));
|
|
49
|
+
expect(pkce.method).toBe("S256");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("two calls differ", () => {
|
|
53
|
+
expect(generatePkce().verifier).not.toBe(generatePkce().verifier);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("generateState", () => {
|
|
58
|
+
it("is unique and base64url across 100 calls", () => {
|
|
59
|
+
const states = Array.from({ length: 100 }, () => generateState());
|
|
60
|
+
expect(new Set(states).size).toBe(100);
|
|
61
|
+
for (const state of states) {
|
|
62
|
+
expect(state).toMatch(BASE64URL_RE);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("buildAuthorizeUrl", () => {
|
|
68
|
+
const base = {
|
|
69
|
+
authorizationEndpoint: "https://eu.posthog.com/oauth/authorize/",
|
|
70
|
+
clientId: POSTHOG_CLIENT_ID,
|
|
71
|
+
redirectUri: "http://127.0.0.1:8423/callback",
|
|
72
|
+
scope: POSTHOG_SCOPES,
|
|
73
|
+
state: "st4te",
|
|
74
|
+
pkce: {
|
|
75
|
+
verifier: "v".repeat(43),
|
|
76
|
+
challenge: "ch4llenge",
|
|
77
|
+
method: "S256" as const,
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
it("sets every OAuth param and never a client_secret", () => {
|
|
82
|
+
const url = new URL(
|
|
83
|
+
buildAuthorizeUrl({ ...base, requiredAccessLevel: "team" }),
|
|
84
|
+
);
|
|
85
|
+
expect(url.origin + url.pathname).toBe(
|
|
86
|
+
"https://eu.posthog.com/oauth/authorize/",
|
|
87
|
+
);
|
|
88
|
+
expect(url.searchParams.get("response_type")).toBe("code");
|
|
89
|
+
expect(url.searchParams.get("client_id")).toBe(POSTHOG_CLIENT_ID);
|
|
90
|
+
expect(url.searchParams.get("redirect_uri")).toBe(base.redirectUri);
|
|
91
|
+
expect(url.searchParams.get("scope")).toBe(POSTHOG_SCOPES);
|
|
92
|
+
expect(url.searchParams.get("state")).toBe("st4te");
|
|
93
|
+
expect(url.searchParams.get("code_challenge")).toBe("ch4llenge");
|
|
94
|
+
expect(url.searchParams.get("code_challenge_method")).toBe("S256");
|
|
95
|
+
expect(url.searchParams.get("required_access_level")).toBe("team");
|
|
96
|
+
expect(url.searchParams.has("client_secret")).toBe(false);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("omits required_access_level when not provided", () => {
|
|
100
|
+
const url = new URL(buildAuthorizeUrl(base));
|
|
101
|
+
expect(url.searchParams.has("required_access_level")).toBe(false);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe("discoverOAuthServer", () => {
|
|
106
|
+
const DOC = {
|
|
107
|
+
issuer: "https://eu.posthog.com",
|
|
108
|
+
authorization_endpoint: "https://eu.posthog.com/oauth/authorize/",
|
|
109
|
+
token_endpoint: "https://eu.posthog.com/oauth/token/",
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
it("requests the RFC 8414 well-known path and returns ok metadata", async () => {
|
|
113
|
+
const { fetchImpl, calls } = fakeFetch(() => json(DOC));
|
|
114
|
+
const result = await discoverOAuthServer({
|
|
115
|
+
privateHost: "https://eu.posthog.com",
|
|
116
|
+
fetchImpl,
|
|
117
|
+
});
|
|
118
|
+
expect(calls[0]?.url).toBe(
|
|
119
|
+
"https://eu.posthog.com/.well-known/oauth-authorization-server",
|
|
120
|
+
);
|
|
121
|
+
expect(result).toEqual({ status: "ok", metadata: DOC });
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("maps 404 and 410 to unsupported", async () => {
|
|
125
|
+
for (const status of [404, 410]) {
|
|
126
|
+
const { fetchImpl } = fakeFetch(() => new Response("nope", { status }));
|
|
127
|
+
const result = await discoverOAuthServer({
|
|
128
|
+
privateHost: "https://ph.selfhosted.example",
|
|
129
|
+
fetchImpl,
|
|
130
|
+
});
|
|
131
|
+
expect(result).toEqual({ status: "unsupported" });
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("flags a 200 missing token_endpoint as malformed", async () => {
|
|
136
|
+
const { fetchImpl } = fakeFetch(() =>
|
|
137
|
+
json({
|
|
138
|
+
issuer: DOC.issuer,
|
|
139
|
+
authorization_endpoint: DOC.authorization_endpoint,
|
|
140
|
+
}),
|
|
141
|
+
);
|
|
142
|
+
const result = await discoverOAuthServer({
|
|
143
|
+
privateHost: "https://eu.posthog.com",
|
|
144
|
+
fetchImpl,
|
|
145
|
+
});
|
|
146
|
+
expect(result.status).toBe("error");
|
|
147
|
+
if (result.status === "error") {
|
|
148
|
+
expect(result.message).toContain("malformed");
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("never throws on transport failure", async () => {
|
|
153
|
+
const fetchImpl = (async () => {
|
|
154
|
+
throw new Error("getaddrinfo ENOTFOUND");
|
|
155
|
+
}) as unknown as typeof fetch;
|
|
156
|
+
const result = await discoverOAuthServer({
|
|
157
|
+
privateHost: "https://eu.posthog.com",
|
|
158
|
+
fetchImpl,
|
|
159
|
+
});
|
|
160
|
+
expect(result.status).toBe("error");
|
|
161
|
+
if (result.status === "error") {
|
|
162
|
+
expect(result.message).toContain("ENOTFOUND");
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe("exchangeCode", () => {
|
|
168
|
+
const OPTS = {
|
|
169
|
+
tokenEndpoint: "https://eu.posthog.com/oauth/token/",
|
|
170
|
+
clientId: POSTHOG_CLIENT_ID,
|
|
171
|
+
code: "auth-code-abc",
|
|
172
|
+
codeVerifier: "verifier-xyz",
|
|
173
|
+
redirectUri: "http://127.0.0.1:8423/callback",
|
|
174
|
+
};
|
|
175
|
+
const TOKENS = {
|
|
176
|
+
access_token: "pha_fixture",
|
|
177
|
+
refresh_token: "phr_fixture",
|
|
178
|
+
token_type: "Bearer",
|
|
179
|
+
expires_in: 36_000,
|
|
180
|
+
scope: POSTHOG_SCOPES,
|
|
181
|
+
scoped_teams: [123],
|
|
182
|
+
scoped_organizations: [],
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
it("POSTs exactly the five form params with no client_secret / auth header", async () => {
|
|
186
|
+
const { fetchImpl, calls } = fakeFetch(() => json(TOKENS));
|
|
187
|
+
const result = await exchangeCode({ ...OPTS, fetchImpl });
|
|
188
|
+
|
|
189
|
+
const call = calls[0];
|
|
190
|
+
expect(call?.init?.method).toBe("POST");
|
|
191
|
+
const headers = call?.init?.headers as Record<string, string>;
|
|
192
|
+
expect(headers.Authorization).toBeUndefined();
|
|
193
|
+
|
|
194
|
+
const body = new URLSearchParams(String(call?.init?.body));
|
|
195
|
+
expect(Object.fromEntries(body.entries())).toEqual({
|
|
196
|
+
grant_type: "authorization_code",
|
|
197
|
+
code: "auth-code-abc",
|
|
198
|
+
redirect_uri: OPTS.redirectUri,
|
|
199
|
+
client_id: POSTHOG_CLIENT_ID,
|
|
200
|
+
code_verifier: "verifier-xyz",
|
|
201
|
+
});
|
|
202
|
+
expect(body.has("client_secret")).toBe(false);
|
|
203
|
+
|
|
204
|
+
expect(result).toEqual(TOKENS);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("throws with the status (never the code/verifier) on a 400", async () => {
|
|
208
|
+
const { fetchImpl } = fakeFetch(() =>
|
|
209
|
+
json({ error: "invalid_grant" }, 400),
|
|
210
|
+
);
|
|
211
|
+
await expect(exchangeCode({ ...OPTS, fetchImpl })).rejects.toSatisfy(
|
|
212
|
+
(err: unknown) => {
|
|
213
|
+
const message = (err as Error).message;
|
|
214
|
+
expect(message).toContain("400");
|
|
215
|
+
expect(message).toContain("invalid_grant");
|
|
216
|
+
expect(message).not.toContain("auth-code-abc");
|
|
217
|
+
expect(message).not.toContain("verifier-xyz");
|
|
218
|
+
return true;
|
|
219
|
+
},
|
|
220
|
+
);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("throws when a 200 omits refresh_token", async () => {
|
|
224
|
+
const { refresh_token: _omitted, ...withoutRefresh } = TOKENS;
|
|
225
|
+
const { fetchImpl } = fakeFetch(() => json(withoutRefresh));
|
|
226
|
+
await expect(exchangeCode({ ...OPTS, fetchImpl })).rejects.toThrow(
|
|
227
|
+
/missing refresh_token/,
|
|
228
|
+
);
|
|
229
|
+
});
|
|
230
|
+
});
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { parseArgs } from "node:util";
|
|
2
|
+
import { confirm } from "@clack/prompts";
|
|
3
|
+
import { openBrowser } from "../lib/browser.js";
|
|
4
|
+
import {
|
|
5
|
+
ConnectError,
|
|
6
|
+
type ConnectFlowDeps,
|
|
7
|
+
runConnectPosthog,
|
|
8
|
+
} from "../lib/connect-flow.js";
|
|
9
|
+
import { startLoopbackServer } from "../lib/loopback.js";
|
|
10
|
+
import { discoverOAuthServer, exchangeCode } from "../lib/oauth.js";
|
|
11
|
+
import { color } from "../lib/output.js";
|
|
12
|
+
import { bail } from "../lib/prompt.js";
|
|
13
|
+
import type { Command, CommandContext } from "./types.js";
|
|
14
|
+
|
|
15
|
+
const usage = `hogsend connect <provider> [--provision-only] [--no-provision] [--no-browser] [--json]
|
|
16
|
+
|
|
17
|
+
Connect this Hogsend instance to an analytics provider via OAuth. Providers:
|
|
18
|
+
|
|
19
|
+
posthog Authorize Hogsend against your PostHog region (PKCE, loopback
|
|
20
|
+
callback on 127.0.0.1), store the refresh token on the instance,
|
|
21
|
+
then provision the PostHog -> Hogsend event loop (a PostHog
|
|
22
|
+
destination posting to /v1/webhooks/posthog).
|
|
23
|
+
|
|
24
|
+
The browser consent must happen on THIS machine (the OAuth callback lands on
|
|
25
|
+
127.0.0.1). The target instance can be anywhere — point --url at it and run
|
|
26
|
+
this command from your laptop, not from an SSH session on the server.
|
|
27
|
+
|
|
28
|
+
Options:
|
|
29
|
+
--provision-only Skip OAuth; (re-)provision the event loop using the
|
|
30
|
+
already-stored credential.
|
|
31
|
+
--no-provision Stop after storing the credential.
|
|
32
|
+
--no-browser Don't spawn a browser; just print the authorize URL.
|
|
33
|
+
--url, --admin-key, --json, -h, --help Global flags as usual.
|
|
34
|
+
|
|
35
|
+
Exit code: 0 when a credential is stored (even if provisioning was skipped),
|
|
36
|
+
1 otherwise.`;
|
|
37
|
+
|
|
38
|
+
async function run(ctx: CommandContext): Promise<void> {
|
|
39
|
+
const { values, positionals } = parseArgs({
|
|
40
|
+
args: ctx.argv,
|
|
41
|
+
allowPositionals: true,
|
|
42
|
+
strict: false,
|
|
43
|
+
options: {
|
|
44
|
+
"provision-only": { type: "boolean", default: false },
|
|
45
|
+
"no-provision": { type: "boolean", default: false },
|
|
46
|
+
"no-browser": { type: "boolean", default: false },
|
|
47
|
+
help: { type: "boolean", short: "h", default: false },
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
if (values.help) {
|
|
52
|
+
ctx.out.log(usage);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const provider = positionals[0];
|
|
57
|
+
if (!provider) {
|
|
58
|
+
ctx.out.fail("missing provider — try: hogsend connect posthog");
|
|
59
|
+
}
|
|
60
|
+
if (provider !== "posthog") {
|
|
61
|
+
ctx.out.fail(`unknown provider "${provider}" — supported: posthog`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (values["provision-only"] && values["no-provision"]) {
|
|
65
|
+
ctx.out.fail("--provision-only and --no-provision are mutually exclusive");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// The PUT carries the OAuth tokens — warn when they'd ride plain http to a
|
|
69
|
+
// non-local instance.
|
|
70
|
+
try {
|
|
71
|
+
const target = new URL(ctx.cfg.baseUrl);
|
|
72
|
+
if (
|
|
73
|
+
target.protocol === "http:" &&
|
|
74
|
+
target.hostname !== "localhost" &&
|
|
75
|
+
target.hostname !== "127.0.0.1"
|
|
76
|
+
) {
|
|
77
|
+
ctx.out.log(
|
|
78
|
+
color.yellow(
|
|
79
|
+
`warning: ${ctx.cfg.baseUrl} is plain http — OAuth tokens will ` +
|
|
80
|
+
"be sent to it unencrypted; use https for remote instances.",
|
|
81
|
+
),
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
} catch {
|
|
85
|
+
// unparseable base URL — the HTTP client will surface it
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
ctx.out.intro(`${color.bgMagenta(color.black(" hogsend "))} connect`);
|
|
89
|
+
|
|
90
|
+
const deps: ConnectFlowDeps = {
|
|
91
|
+
http: ctx.http,
|
|
92
|
+
out: ctx.out,
|
|
93
|
+
interactive: ctx.out.interactive,
|
|
94
|
+
discover: discoverOAuthServer,
|
|
95
|
+
startLoopback: startLoopbackServer,
|
|
96
|
+
exchangeCode,
|
|
97
|
+
openBrowser,
|
|
98
|
+
confirm: async (message) => bail(await confirm({ message })),
|
|
99
|
+
now: () => new Date(),
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
const result = await runConnectPosthog(deps, {
|
|
104
|
+
provisionOnly: Boolean(values["provision-only"]),
|
|
105
|
+
noProvision: Boolean(values["no-provision"]),
|
|
106
|
+
noBrowser: Boolean(values["no-browser"]),
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
if (ctx.json) {
|
|
110
|
+
// One document; ConnectResult carries no token material by invariant.
|
|
111
|
+
ctx.out.json({ ok: true, ...result });
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
ctx.out.outro(
|
|
115
|
+
color.green(
|
|
116
|
+
`connect: posthog ${
|
|
117
|
+
result.verdict === "connected"
|
|
118
|
+
? "connected"
|
|
119
|
+
: "connected (loop not provisioned)"
|
|
120
|
+
}`,
|
|
121
|
+
),
|
|
122
|
+
);
|
|
123
|
+
} catch (error) {
|
|
124
|
+
if (error instanceof ConnectError) {
|
|
125
|
+
if (ctx.json) {
|
|
126
|
+
ctx.out.json({
|
|
127
|
+
ok: false,
|
|
128
|
+
verdict: error.verdict,
|
|
129
|
+
error: error.message,
|
|
130
|
+
hint: error.hint,
|
|
131
|
+
});
|
|
132
|
+
process.exit(1);
|
|
133
|
+
}
|
|
134
|
+
ctx.out.note(
|
|
135
|
+
error.hint ? `${error.message}\n\n${error.hint}` : error.message,
|
|
136
|
+
);
|
|
137
|
+
ctx.out.outro(color.red(`connect: ${error.verdict}`));
|
|
138
|
+
process.exit(1);
|
|
139
|
+
}
|
|
140
|
+
throw error; // router renders unexpected errors
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export const connectCommand: Command = {
|
|
145
|
+
name: "connect",
|
|
146
|
+
summary: "Connect an analytics provider via OAuth (posthog)",
|
|
147
|
+
usage,
|
|
148
|
+
run,
|
|
149
|
+
};
|
package/src/commands/doctor.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { parseArgs } from "node:util";
|
|
2
|
+
import { loadDotEnv } from "../lib/config.js";
|
|
2
3
|
import { isHttpError } from "../lib/http.js";
|
|
3
4
|
import { color } from "../lib/output.js";
|
|
4
5
|
import { skillsStaleness } from "../lib/skills.js";
|
|
@@ -22,6 +23,34 @@ function skillsNudge(ctx: CommandContext): void {
|
|
|
22
23
|
);
|
|
23
24
|
}
|
|
24
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Best-effort nudge: PostHog capture configured (`POSTHOG_API_KEY` in the
|
|
28
|
+
* cwd's `.env` or process env) without `POSTHOG_PERSONAL_API_KEY` means
|
|
29
|
+
* person READS are silently disabled — the phc_ project key is write-only by
|
|
30
|
+
* PostHog's design, so timezone resolution falls back to contact properties.
|
|
31
|
+
* Warn-not-fail: capture and person writes still work.
|
|
32
|
+
*/
|
|
33
|
+
function analyticsNudge(ctx: CommandContext): void {
|
|
34
|
+
if (ctx.json) return;
|
|
35
|
+
const dotenv = loadDotEnv(process.cwd());
|
|
36
|
+
const captureKey = process.env.POSTHOG_API_KEY ?? dotenv.POSTHOG_API_KEY;
|
|
37
|
+
const personalKey =
|
|
38
|
+
process.env.POSTHOG_PERSONAL_API_KEY ?? dotenv.POSTHOG_PERSONAL_API_KEY;
|
|
39
|
+
if (!captureKey || personalKey) return;
|
|
40
|
+
ctx.out.note(
|
|
41
|
+
[
|
|
42
|
+
"POSTHOG_API_KEY is set without POSTHOG_PERSONAL_API_KEY — person",
|
|
43
|
+
"property READS are disabled (the phc_ project key is write-only by",
|
|
44
|
+
"PostHog's design), so per-user timezone resolution falls back to",
|
|
45
|
+
"contact properties. Capture and person WRITES are unaffected.",
|
|
46
|
+
"",
|
|
47
|
+
`Fix: create a personal API key scoped ${color.cyan("person:read")} and set ${color.cyan("POSTHOG_PERSONAL_API_KEY")}.`,
|
|
48
|
+
`Docs: ${color.cyan("https://hogsend.com/docs/guides/analytics-access")}`,
|
|
49
|
+
].join("\n"),
|
|
50
|
+
"PostHog person reads disabled",
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
25
54
|
const usage = `hogsend doctor [--url <baseUrl>] [--admin-key <key>] [--json]
|
|
26
55
|
|
|
27
56
|
Probe a running Hogsend instance via GET /v1/health and report its health:
|
|
@@ -221,6 +250,7 @@ async function run(ctx: CommandContext): Promise<void> {
|
|
|
221
250
|
ctx.out.note(lines.join("\n"), "Doctor");
|
|
222
251
|
|
|
223
252
|
skillsNudge(ctx);
|
|
253
|
+
analyticsNudge(ctx);
|
|
224
254
|
|
|
225
255
|
if (ok) {
|
|
226
256
|
ctx.out.outro(color.green("doctor: ok"));
|
package/src/commands/index.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { campaignsCommand } from "./campaigns.js";
|
|
2
|
+
import { connectCommand } from "./connect.js";
|
|
2
3
|
import { contactsCommand } from "./contacts.js";
|
|
3
4
|
import { devCommand } from "./dev.js";
|
|
4
5
|
import { doctorCommand } from "./doctor.js";
|
|
@@ -35,6 +36,7 @@ export const commands: Command[] = [
|
|
|
35
36
|
campaignsCommand,
|
|
36
37
|
webhooksCommand,
|
|
37
38
|
domainCommand,
|
|
39
|
+
connectCommand,
|
|
38
40
|
hatchetCommand,
|
|
39
41
|
studioCommand,
|
|
40
42
|
devCommand,
|
package/src/commands/studio.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { spawn } from "node:child_process";
|
|
2
1
|
import { createReadStream, existsSync, readFileSync, statSync } from "node:fs";
|
|
3
2
|
import { createServer } from "node:http";
|
|
4
3
|
import { extname, join, normalize, resolve, sep } from "node:path";
|
|
5
4
|
import { fileURLToPath } from "node:url";
|
|
6
5
|
import { parseArgs } from "node:util";
|
|
6
|
+
import { openBrowser } from "../lib/browser.js";
|
|
7
7
|
import { color } from "../lib/output.js";
|
|
8
8
|
import { runStudioAdmin } from "./studio-admin.js";
|
|
9
9
|
import type { Command, CommandContext } from "./types.js";
|
|
@@ -115,21 +115,6 @@ function indexHtml(distPath: string, baseUrl: string | undefined): string {
|
|
|
115
115
|
return `${inject}${raw}`;
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
-
/** Open a URL in the OS default browser (best-effort, never throws). */
|
|
119
|
-
function openBrowser(url: string): void {
|
|
120
|
-
const platform = process.platform;
|
|
121
|
-
const cmd =
|
|
122
|
-
platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open";
|
|
123
|
-
const args = platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
124
|
-
try {
|
|
125
|
-
const child = spawn(cmd, args, { stdio: "ignore", detached: true });
|
|
126
|
-
child.on("error", () => {});
|
|
127
|
-
child.unref();
|
|
128
|
-
} catch {
|
|
129
|
-
// best-effort
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
|
|
133
118
|
async function run(ctx: CommandContext): Promise<void> {
|
|
134
119
|
// Subcommand dispatch: `hogsend studio admin <create|reset|list> ...`.
|
|
135
120
|
// Route before the static-server flag parsing so the admin flags are owned
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
/** Open a URL in the OS default browser. Best-effort: returns false instead of throwing. */
|
|
4
|
+
export function openBrowser(url: string): boolean {
|
|
5
|
+
const platform = process.platform;
|
|
6
|
+
const cmd =
|
|
7
|
+
platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open";
|
|
8
|
+
const args = platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
9
|
+
try {
|
|
10
|
+
const child = spawn(cmd, args, { stdio: "ignore", detached: true });
|
|
11
|
+
child.on("error", () => {});
|
|
12
|
+
child.unref();
|
|
13
|
+
return true;
|
|
14
|
+
} catch {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
}
|