@hogsend/cli 0.21.1 → 0.22.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.
@@ -1,6 +1,12 @@
1
1
  import { parseArgs } from "node:util";
2
- import { confirm, select, text } from "@clack/prompts";
2
+ import { confirm, password, select, text } from "@clack/prompts";
3
3
  import { openBrowser } from "../lib/browser.js";
4
+ import {
5
+ ConnectDiscordError,
6
+ type ConnectDiscordFlowDeps,
7
+ type DiscordSecrets,
8
+ runConnectDiscord,
9
+ } from "../lib/connect-discord-flow.js";
4
10
  import {
5
11
  ConnectError,
6
12
  type ConnectFlowDeps,
@@ -12,20 +18,26 @@ import { color } from "../lib/output.js";
12
18
  import { bail } from "../lib/prompt.js";
13
19
  import type { Command, CommandContext } from "./types.js";
14
20
 
15
- const usage = `hogsend connect <provider> [--posthog-host <url>] [--provision-only] [--no-provision] [--no-browser] [--json]
21
+ const usage = `hogsend connect <provider> [options] [--no-browser] [--json]
16
22
 
17
- Connect this Hogsend instance to an analytics provider via OAuth. Providers:
23
+ Connect this Hogsend instance to a provider. Providers:
18
24
 
19
25
  posthog Authorize Hogsend against your PostHog region (PKCE, loopback
20
26
  callback on 127.0.0.1), store the refresh token on the instance,
21
27
  then provision the PostHog -> Hogsend event loop (a PostHog
22
28
  destination posting to /v1/webhooks/posthog).
23
29
 
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.
30
+ discord One-time Discord developer-portal setup: paste the four portal
31
+ values (application id, public key, bot token, client secret),
32
+ store them on the instance, wire the interactions endpoint
33
+ (PATCH /applications/@me) server-side, then open the one-click
34
+ bot-install link and capture the guild id.
35
+
36
+ For posthog, the browser consent must happen on THIS machine (the OAuth
37
+ callback lands on 127.0.0.1). The target instance can be anywhere — point --url
38
+ at it and run this command from your laptop, not from an SSH session.
27
39
 
28
- Options:
40
+ posthog options:
29
41
  --posthog-host PostHog app/private host to authorize against, e.g.
30
42
  https://eu.posthog.com or https://us.posthog.com (NOT the
31
43
  i. ingestion host). Required when the instance has no
@@ -33,11 +45,17 @@ Options:
33
45
  --provision-only Skip OAuth; (re-)provision the event loop using the
34
46
  already-stored credential.
35
47
  --no-provision Stop after storing the credential.
36
- --no-browser Don't spawn a browser; just print the authorize URL.
48
+
49
+ discord options:
50
+ --status Read-only: report what's stored/wired and the captured
51
+ guild id. Never prompts or stores anything.
52
+
53
+ Shared options:
54
+ --no-browser Don't spawn a browser; just print the URL(s).
37
55
  --url, --admin-key, --json, -h, --help Global flags as usual.
38
56
 
39
- Exit code: 0 when a credential is stored (even if provisioning was skipped),
40
- 1 otherwise.`;
57
+ Exit code: 0 when the connection is stored (even if a follow-up step was
58
+ skipped), 1 otherwise.`;
41
59
 
42
60
  async function run(ctx: CommandContext): Promise<void> {
43
61
  const { values, positionals } = parseArgs({
@@ -49,6 +67,7 @@ async function run(ctx: CommandContext): Promise<void> {
49
67
  "provision-only": { type: "boolean", default: false },
50
68
  "no-provision": { type: "boolean", default: false },
51
69
  "no-browser": { type: "boolean", default: false },
70
+ status: { type: "boolean", default: false },
52
71
  help: { type: "boolean", short: "h", default: false },
53
72
  },
54
73
  });
@@ -62,8 +81,17 @@ async function run(ctx: CommandContext): Promise<void> {
62
81
  if (!provider) {
63
82
  ctx.out.fail("missing provider — try: hogsend connect posthog");
64
83
  }
84
+ if (provider === "discord") {
85
+ await runDiscord(ctx, {
86
+ noBrowser: Boolean(values["no-browser"]),
87
+ statusOnly: Boolean(values.status),
88
+ });
89
+ return;
90
+ }
65
91
  if (provider !== "posthog") {
66
- ctx.out.fail(`unknown provider "${provider}" — supported: posthog`);
92
+ ctx.out.fail(
93
+ `unknown provider "${provider}" — supported: posthog, discord`,
94
+ );
67
95
  }
68
96
 
69
97
  if (values["provision-only"] && values["no-provision"]) {
@@ -72,22 +100,13 @@ async function run(ctx: CommandContext): Promise<void> {
72
100
 
73
101
  // The PUT carries the OAuth tokens — warn when they'd ride plain http to a
74
102
  // non-local instance.
75
- try {
76
- const target = new URL(ctx.cfg.baseUrl);
77
- if (
78
- target.protocol === "http:" &&
79
- target.hostname !== "localhost" &&
80
- target.hostname !== "127.0.0.1"
81
- ) {
82
- ctx.out.log(
83
- color.yellow(
84
- `warning: ${ctx.cfg.baseUrl} is plain http — OAuth tokens will ` +
85
- "be sent to it unencrypted; use https for remote instances.",
86
- ),
87
- );
88
- }
89
- } catch {
90
- // unparseable base URL — the HTTP client will surface it
103
+ if (isPlainHttpRemote(ctx.cfg.baseUrl)) {
104
+ ctx.out.log(
105
+ color.yellow(
106
+ `warning: ${ctx.cfg.baseUrl} is plain http — OAuth tokens will ` +
107
+ "be sent to it unencrypted; use https for remote instances.",
108
+ ),
109
+ );
91
110
  }
92
111
 
93
112
  ctx.out.intro(`${color.bgMagenta(color.black(" hogsend "))} connect`);
@@ -180,9 +199,119 @@ async function run(ctx: CommandContext): Promise<void> {
180
199
  }
181
200
  }
182
201
 
202
+ /** True when the base URL is plain http to a non-loopback host. */
203
+ function isPlainHttpRemote(baseUrl: string): boolean {
204
+ try {
205
+ const target = new URL(baseUrl);
206
+ return (
207
+ target.protocol === "http:" &&
208
+ target.hostname !== "localhost" &&
209
+ target.hostname !== "127.0.0.1"
210
+ );
211
+ } catch {
212
+ // unparseable base URL — the HTTP client will surface it
213
+ return false;
214
+ }
215
+ }
216
+
217
+ /**
218
+ * The `discord` branch: a one-time portal paste, server-side wiring, and the
219
+ * one-click bot-install link. Mirrors the posthog branch's deps/error
220
+ * handling; the testable orchestration lives in connect-discord-flow.ts.
221
+ */
222
+ async function runDiscord(
223
+ ctx: CommandContext,
224
+ opts: { noBrowser: boolean; statusOnly: boolean },
225
+ ): Promise<void> {
226
+ // The PUT carries the bot token + client secret — REFUSE plain http to a
227
+ // non-loopback instance (a secret must never ride unencrypted over the wire).
228
+ // --status never PUTs, so it's exempt.
229
+ if (!opts.statusOnly && isPlainHttpRemote(ctx.cfg.baseUrl)) {
230
+ ctx.out.fail(
231
+ `${ctx.cfg.baseUrl} is plain http — refusing to send the Discord bot ` +
232
+ "token + client secret to a remote instance unencrypted. Use https, " +
233
+ "or run --status (which sends no secrets).",
234
+ );
235
+ }
236
+
237
+ ctx.out.intro(`${color.bgMagenta(color.black(" hogsend "))} connect`);
238
+
239
+ const deps: ConnectDiscordFlowDeps = {
240
+ http: ctx.http,
241
+ out: ctx.out,
242
+ interactive: ctx.out.interactive,
243
+ confirm: async (message) => bail(await confirm({ message })),
244
+ openBrowser,
245
+ promptSecrets: async (): Promise<DiscordSecrets> => {
246
+ const appId = bail(
247
+ await text({
248
+ message: "Discord Application ID (OAuth2 -> Client ID)",
249
+ placeholder: "1234567890123456789",
250
+ }),
251
+ ) as string;
252
+ const publicKey = bail(
253
+ await text({
254
+ message: "Public Key (General Information -> Public Key)",
255
+ placeholder: "abc123...",
256
+ }),
257
+ ) as string;
258
+ const botToken = bail(
259
+ await password({ message: "Bot Token (Bot -> Reset Token)" }),
260
+ ) as string;
261
+ const clientSecret = bail(
262
+ await password({
263
+ message: "Client Secret (OAuth2 -> Client Secret)",
264
+ }),
265
+ ) as string;
266
+ return { appId, publicKey, botToken, clientSecret };
267
+ },
268
+ now: () => new Date(),
269
+ };
270
+
271
+ try {
272
+ const result = await runConnectDiscord(deps, {
273
+ noBrowser: opts.noBrowser,
274
+ statusOnly: opts.statusOnly,
275
+ });
276
+
277
+ if (ctx.json) {
278
+ // One document; ConnectDiscordResult carries no secret material.
279
+ ctx.out.json({ ok: true, ...result });
280
+ return;
281
+ }
282
+ ctx.out.outro(
283
+ color.green(
284
+ `connect: discord ${
285
+ result.verdict === "connected"
286
+ ? "connected"
287
+ : "secrets stored (interactions not wired)"
288
+ }`,
289
+ ),
290
+ );
291
+ } catch (error) {
292
+ if (error instanceof ConnectDiscordError) {
293
+ if (ctx.json) {
294
+ ctx.out.json({
295
+ ok: false,
296
+ verdict: error.verdict,
297
+ error: error.message,
298
+ hint: error.hint,
299
+ });
300
+ process.exit(1);
301
+ }
302
+ ctx.out.note(
303
+ error.hint ? `${error.message}\n\n${error.hint}` : error.message,
304
+ );
305
+ ctx.out.outro(color.red(`connect: ${error.verdict}`));
306
+ process.exit(1);
307
+ }
308
+ throw error; // router renders unexpected errors
309
+ }
310
+ }
311
+
183
312
  export const connectCommand: Command = {
184
313
  name: "connect",
185
- summary: "Connect an analytics provider via OAuth (posthog)",
314
+ summary: "Connect a provider (posthog OAuth, discord bot)",
186
315
  usage,
187
316
  run,
188
317
  };
@@ -0,0 +1,427 @@
1
+ import type { AdminClient } from "./http.js";
2
+ import { isHttpError } from "./http.js";
3
+ import type { Output } from "./output.js";
4
+
5
+ /**
6
+ * The testable orchestration behind `hogsend connect discord`. Every side
7
+ * effect (HTTP, browser, prompt, clock, randomness) is injected via
8
+ * {@link ConnectDiscordFlowDeps}; the command file stays a thin argv/usage
9
+ * wrapper, mirroring {@link runConnectPosthog} / connect-flow.ts.
10
+ *
11
+ * Unlike PostHog (which is a real OAuth handshake on the laptop), the Discord
12
+ * bot flow is a one-time portal paste: the operator creates a Discord
13
+ * application, copies four values (app id, public key, bot token, client
14
+ * secret) into the prompt, the CLI PUTs them onto the instance, the server
15
+ * wires the interactions endpoint via PATCH /applications/@me, and the CLI
16
+ * opens the one-click bot-install link. The guild id lands via the advanced
17
+ * bot-auth callback (the server captures it in the derived credential).
18
+ *
19
+ * SECRET HYGIENE INVARIANT: no bot token, client secret, or public key is ever
20
+ * passed to any `out.*` call or included in the returned
21
+ * {@link ConnectDiscordResult}. The CLI also refuses to PUT secrets to a
22
+ * plain-http non-loopback instance.
23
+ */
24
+
25
+ /** Mirror of GET /v1/admin/connectors/discord/connect-info (engine route). */
26
+ export interface DiscordConnectInfoResponse {
27
+ providerId: "discord";
28
+ apiPublicUrl: string;
29
+ /** ${API_PUBLIC_URL}/v1/connectors/discord/oauth/callback */
30
+ redirectUri: string;
31
+ /** ${API_PUBLIC_URL}/v1/connectors/discord/interactions */
32
+ interactionsUrl: string;
33
+ /** Whether CONNECTOR_INGRESS_SECRET is set on the instance (advisory). */
34
+ ingressSecretConfigured: boolean;
35
+ /** Whether a derived `discord` credential is already stored. */
36
+ credentialStored: boolean;
37
+ /** The captured guild id once the bot install completes (else null). */
38
+ guildId: string | null;
39
+ /** Whether the interactions endpoint has been wired (PATCH succeeded). */
40
+ botInstalled: boolean;
41
+ /**
42
+ * The one-click bot-install URL, SERVER-MINTED with a signed CSRF `state`.
43
+ * `null` until the Discord secrets are stored (the server has no app id to
44
+ * build it from yet). This is the SINGLE canonical install URL — the CLI
45
+ * opens this rather than building one client-side, so the unauthenticated
46
+ * oauth callback always sees a valid state.
47
+ */
48
+ installUrl: string | null;
49
+ }
50
+
51
+ export type ConnectDiscordVerdict =
52
+ | "connected" // secrets stored + interactions wired
53
+ | "secrets_stored_not_wired"; // secrets stored; wiring deferred (loopback)
54
+
55
+ export type ConnectDiscordFailure =
56
+ | "not_configured" // connect-info reports no usable public url (non-interactive)
57
+ | "api_public_url_unreachable" // instance API_PUBLIC_URL is a loopback address
58
+ | "wire_failed" // PATCH /applications/@me failed
59
+ | "store_failed" // PUT secrets failed
60
+ | "paste_aborted"; // a required value was left blank
61
+
62
+ export class ConnectDiscordError extends Error {
63
+ readonly verdict: ConnectDiscordFailure;
64
+ readonly hint?: string;
65
+
66
+ constructor(verdict: ConnectDiscordFailure, message: string, hint?: string) {
67
+ super(message);
68
+ this.name = "ConnectDiscordError";
69
+ this.verdict = verdict;
70
+ this.hint = hint;
71
+ }
72
+ }
73
+
74
+ export interface ConnectDiscordResult {
75
+ verdict: ConnectDiscordVerdict;
76
+ providerId: "discord";
77
+ /** cfg.baseUrl — the Hogsend instance this run targeted. */
78
+ instance: string;
79
+ /** Whether the four secrets were stored on the instance this run. */
80
+ secretsStored: boolean;
81
+ /** Whether the interactions endpoint was wired (PATCH /applications/@me). */
82
+ wired: boolean;
83
+ /**
84
+ * The SERVER-MINTED bot-install URL the operator opens to add the bot to a
85
+ * guild (carries a signed CSRF `state` the oauth callback verifies). `null`
86
+ * when the server has no install URL yet (secrets not stored / `--status`
87
+ * before connect).
88
+ */
89
+ botInstallUrl: string | null;
90
+ /** The guild id captured via the bot-auth callback, when the poll saw it. */
91
+ guildId: string | null;
92
+ }
93
+
94
+ /** The four values pasted from the Discord developer portal. */
95
+ export interface DiscordSecrets {
96
+ appId: string;
97
+ publicKey: string;
98
+ botToken: string;
99
+ clientSecret: string;
100
+ }
101
+
102
+ export interface ConnectDiscordFlowDeps {
103
+ http: AdminClient;
104
+ out: Output;
105
+ /** ctx.out.interactive — gates the secret-paste prompts. */
106
+ interactive: boolean;
107
+ /**
108
+ * Collect the four pasted portal values. Injected (a bail-wrapped clack
109
+ * text/password sequence in the command file) so tests never prompt. Only
110
+ * called in interactive mode; non-interactive runs fail `not_configured`.
111
+ */
112
+ promptSecrets: () => Promise<DiscordSecrets>;
113
+ /** bail-wrapped clack confirm; injected so tests never prompt. */
114
+ confirm: (message: string) => Promise<boolean>;
115
+ openBrowser: (url: string) => boolean;
116
+ now: () => Date;
117
+ }
118
+
119
+ export interface ConnectDiscordFlowOptions {
120
+ noBrowser: boolean;
121
+ /** --status: read connect-info and report; never prompts or PUTs. */
122
+ statusOnly: boolean;
123
+ }
124
+
125
+ // --- UX text — exact strings -----------------------------------------------
126
+
127
+ const HINT_NOT_CONFIGURED =
128
+ "Run `hogsend connect discord` interactively (in a terminal) to paste the " +
129
+ "four Discord portal values. To only read the current state, re-run with " +
130
+ "--status (which never prompts).";
131
+
132
+ const HINT_LOOPBACK =
133
+ "Discord validates the interactions endpoint by PINGing it synchronously, " +
134
+ "so it must be publicly reachable. Run this against your DEPLOYED instance " +
135
+ "(point --url at it); the secrets are stored either way.";
136
+
137
+ /**
138
+ * Loopback detector — kept in LOCKSTEP with the engine's
139
+ * `isLoopbackPublicUrl` (packages/engine/src/routes/admin/analytics.ts) and
140
+ * connect-flow.ts's copy; the CLI has no engine dependency.
141
+ */
142
+ function isLoopbackUrl(publicUrl: string): boolean {
143
+ try {
144
+ const host = new URL(publicUrl).hostname.toLowerCase();
145
+ return (
146
+ host === "localhost" ||
147
+ host === "127.0.0.1" ||
148
+ host === "0.0.0.0" ||
149
+ host === "[::1]" ||
150
+ host === "::1" ||
151
+ host.endsWith(".localhost")
152
+ );
153
+ } catch {
154
+ return false;
155
+ }
156
+ }
157
+
158
+ const errMsg = (err: unknown): string =>
159
+ err instanceof Error ? err.message : String(err);
160
+
161
+ /**
162
+ * Build the one-click bot-install authorize URL.
163
+ *
164
+ * NOTE: the live flow does NOT use this — the install URL is SERVER-MINTED
165
+ * (`connect-info.installUrl`, carrying a signed CSRF `state` the unauthenticated
166
+ * oauth callback verifies). The CLI cannot mint a state the server would accept
167
+ * (no `BETTER_AUTH_SECRET`), so a client-built URL is unusable. Kept only as a
168
+ * pure URL-shape helper for tests.
169
+ */
170
+ export function buildBotInstallUrl(opts: {
171
+ applicationId: string;
172
+ redirectUri: string;
173
+ state: string;
174
+ }): string {
175
+ const url = new URL("https://discord.com/oauth2/authorize");
176
+ url.searchParams.set("client_id", opts.applicationId);
177
+ url.searchParams.set("response_type", "code");
178
+ url.searchParams.set("scope", "bot applications.commands");
179
+ url.searchParams.set("redirect_uri", opts.redirectUri);
180
+ url.searchParams.set("state", opts.state);
181
+ // Advanced bot-auth: the callback receives `guild_id` so the server can
182
+ // capture which guild the bot was installed into.
183
+ url.searchParams.set("integration_type", "0");
184
+ return url.toString();
185
+ }
186
+
187
+ function portalSteps(info: DiscordConnectInfoResponse): string {
188
+ return [
189
+ "One-time Discord developer portal setup:",
190
+ "",
191
+ " 1. https://discord.com/developers/applications -> New Application",
192
+ " 2. Bot tab -> Reset Token -> copy the BOT TOKEN. Enable the privileged",
193
+ " gateway intents: SERVER MEMBERS, MESSAGE CONTENT, PRESENCE.",
194
+ " 3. OAuth2 tab -> copy the CLIENT ID (= application id) and CLIENT",
195
+ " SECRET. Add this exact redirect URL:",
196
+ ` ${info.redirectUri}`,
197
+ " 4. General Information tab -> copy the PUBLIC KEY.",
198
+ "",
199
+ "The interactions endpoint is wired for you (you do NOT paste it):",
200
+ ` ${info.interactionsUrl}`,
201
+ ].join("\n");
202
+ }
203
+
204
+ /**
205
+ * Run the full Discord connect flow (or the `--status` read-only shortcut).
206
+ * Resolves with a {@link ConnectDiscordResult} whenever the secrets are stored
207
+ * (even if wiring was deferred for a loopback instance); throws
208
+ * {@link ConnectDiscordError} otherwise.
209
+ */
210
+ export async function runConnectDiscord(
211
+ deps: ConnectDiscordFlowDeps,
212
+ opts: ConnectDiscordFlowOptions,
213
+ ): Promise<ConnectDiscordResult> {
214
+ const base = deps.http.cfg.baseUrl;
215
+
216
+ // 1. Ask the server what it knows — the CLI needs no Discord env vars.
217
+ const info = await deps.out.step(
218
+ `GET ${base}/v1/admin/connectors/discord/connect-info`,
219
+ () =>
220
+ deps.http.get<DiscordConnectInfoResponse>(
221
+ "/v1/admin/connectors/discord/connect-info",
222
+ ),
223
+ );
224
+
225
+ // --status: report what's stored/wired and stop. Never prompts or PUTs.
226
+ if (opts.statusOnly) {
227
+ deps.out.note(
228
+ [
229
+ "Discord connection status",
230
+ ` instance ${base}`,
231
+ ` secrets stored ${info.credentialStored ? "yes" : "no"}`,
232
+ ` interactions ${info.botInstalled ? "wired" : "not wired"}`,
233
+ ` guild id ${info.guildId ?? "(not yet captured)"}`,
234
+ ` ingress secret ${info.ingressSecretConfigured ? "set" : "unset"}`,
235
+ ` install url ${info.installUrl ?? "(stored secrets first)"}`,
236
+ ].join("\n"),
237
+ );
238
+ return {
239
+ verdict: info.botInstalled ? "connected" : "secrets_stored_not_wired",
240
+ providerId: "discord",
241
+ instance: base,
242
+ secretsStored: info.credentialStored,
243
+ wired: info.botInstalled,
244
+ // The SERVER-MINTED install URL (carries a valid signed state). Null until
245
+ // the secrets are stored — there's nothing for the operator to open yet.
246
+ botInstallUrl: info.installUrl,
247
+ guildId: info.guildId,
248
+ };
249
+ }
250
+
251
+ // 2. Print the exact portal steps + the redirect URI to register.
252
+ deps.out.note(portalSteps(info), "Discord portal");
253
+
254
+ // 3. Collect + store the four pasted values. Non-interactive runs can't
255
+ // safely prompt for secrets, so they fail not_configured.
256
+ if (!deps.interactive) {
257
+ throw new ConnectDiscordError(
258
+ "not_configured",
259
+ "Discord connect needs the four portal values pasted interactively",
260
+ HINT_NOT_CONFIGURED,
261
+ );
262
+ }
263
+
264
+ const secrets = await deps.promptSecrets();
265
+ if (
266
+ !secrets.appId.trim() ||
267
+ !secrets.publicKey.trim() ||
268
+ !secrets.botToken.trim() ||
269
+ !secrets.clientSecret.trim()
270
+ ) {
271
+ throw new ConnectDiscordError(
272
+ "paste_aborted",
273
+ "all four values (application id, public key, bot token, client " +
274
+ "secret) are required",
275
+ );
276
+ }
277
+
278
+ try {
279
+ await deps.out.step(`PUT ${base}/v1/admin/connectors/discord/secrets`, () =>
280
+ deps.http.put("/v1/admin/connectors/discord/secrets", {
281
+ appId: secrets.appId.trim(),
282
+ publicKey: secrets.publicKey.trim(),
283
+ botToken: secrets.botToken.trim(),
284
+ clientSecret: secrets.clientSecret.trim(),
285
+ }),
286
+ );
287
+ } catch (err) {
288
+ throw new ConnectDiscordError("store_failed", errMsg(err));
289
+ }
290
+
291
+ // Re-read connect-info: now that the app id is stored, the server mints the
292
+ // canonical install URL (with a signed CSRF `state`). The CLI NEVER builds
293
+ // this URL itself — a client-minted state would be rejected by the callback.
294
+ const stored = await deps.out.step(
295
+ "Reading the server-minted install URL",
296
+ () =>
297
+ deps.http.get<DiscordConnectInfoResponse>(
298
+ "/v1/admin/connectors/discord/connect-info",
299
+ ),
300
+ );
301
+ const botInstallUrl = stored.installUrl;
302
+
303
+ // 4. Wire the interactions endpoint server-side (PATCH /applications/@me).
304
+ // Discord PINGs interactionsUrl synchronously to validate it, so a
305
+ // loopback API_PUBLIC_URL cannot be wired — defer and tell the operator.
306
+ if (isLoopbackUrl(info.apiPublicUrl)) {
307
+ deps.out.note(
308
+ `Secrets stored, but this instance's API_PUBLIC_URL is ${info.apiPublicUrl} ` +
309
+ "(a loopback address). Discord cannot reach the interactions endpoint, " +
310
+ "so wiring was skipped.\n\nWire it against your deployed instance:\n\n" +
311
+ " hogsend connect discord --url https://your-instance",
312
+ "Instance not publicly reachable",
313
+ );
314
+ return {
315
+ verdict: "secrets_stored_not_wired",
316
+ providerId: "discord",
317
+ instance: base,
318
+ secretsStored: true,
319
+ wired: false,
320
+ botInstallUrl,
321
+ guildId: info.guildId,
322
+ };
323
+ }
324
+
325
+ try {
326
+ await deps.out.step(`POST ${base}/v1/admin/connectors/discord/wire`, () =>
327
+ deps.http.post("/v1/admin/connectors/discord/wire", {}),
328
+ );
329
+ } catch (err) {
330
+ // The wire route 409s when API_PUBLIC_URL is loopback (belt-and-suspenders
331
+ // with the local check above) — surface it as the unreachable verdict.
332
+ if (
333
+ isHttpError(err) &&
334
+ err.status === 409 &&
335
+ httpErrorBody(err) === "api_public_url_unreachable"
336
+ ) {
337
+ throw new ConnectDiscordError(
338
+ "api_public_url_unreachable",
339
+ `API_PUBLIC_URL is ${info.apiPublicUrl} — Discord cannot PING the ` +
340
+ "interactions endpoint",
341
+ HINT_LOOPBACK,
342
+ );
343
+ }
344
+ throw new ConnectDiscordError("wire_failed", errMsg(err));
345
+ }
346
+
347
+ // 5. Open the SERVER-MINTED one-click bot-install link and capture the guild
348
+ // id. `botInstallUrl` is null only if the server somehow has no app id
349
+ // despite the just-successful store — guard rather than open `null`.
350
+ let opened = false;
351
+ if (botInstallUrl) {
352
+ deps.out.note(
353
+ ["Add the bot to your server", ` ${botInstallUrl}`].join("\n"),
354
+ );
355
+ opened = opts.noBrowser ? false : deps.openBrowser(botInstallUrl);
356
+ deps.out.log(
357
+ opened
358
+ ? "Opening your browser to install the bot. Approve the install in " +
359
+ "your server, then this command finishes capturing the guild id."
360
+ : "Open this URL in a browser to install the bot into your server:",
361
+ );
362
+ deps.out.log(` ${botInstallUrl}`);
363
+ } else {
364
+ deps.out.log(
365
+ "Secrets stored, but the server returned no install URL. Re-run " +
366
+ "`hogsend connect discord --status` to read it.",
367
+ );
368
+ }
369
+
370
+ // 6. Poll connect-info for the captured guild id (the server records it on
371
+ // the bot-auth callback). Best-effort: a still-null guild id is fine — the
372
+ // operator may complete the install later, then re-run with --status.
373
+ // Seed from the post-store read (freshest) so a guild already captured
374
+ // short-circuits the confirm prompt + the extra GET.
375
+ let guildId = stored.guildId;
376
+ if (!guildId && !opts.noBrowser) {
377
+ const proceed = await deps.confirm(
378
+ "Once you've approved the bot install in your browser, press Enter to " +
379
+ "capture the guild id (or skip and re-run with --status later)",
380
+ );
381
+ if (proceed) {
382
+ try {
383
+ const refreshed = await deps.out.step(
384
+ "Capturing the installed guild id",
385
+ () =>
386
+ deps.http.get<DiscordConnectInfoResponse>(
387
+ "/v1/admin/connectors/discord/connect-info",
388
+ ),
389
+ );
390
+ guildId = refreshed.guildId;
391
+ } catch {
392
+ // soft — the install is wired; the guild id can be read later.
393
+ }
394
+ }
395
+ }
396
+
397
+ if (!guildId) {
398
+ deps.out.log(
399
+ "Bot install not yet detected. Complete it in your browser, then run " +
400
+ "`hogsend connect discord --status` to capture the guild id.",
401
+ );
402
+ }
403
+
404
+ return {
405
+ verdict: "connected",
406
+ providerId: "discord",
407
+ instance: base,
408
+ secretsStored: true,
409
+ wired: true,
410
+ botInstallUrl,
411
+ guildId,
412
+ };
413
+ }
414
+
415
+ const httpErrorBody = (err: unknown): string | undefined => {
416
+ if (!isHttpError(err)) return undefined;
417
+ const body = err.body;
418
+ if (
419
+ body &&
420
+ typeof body === "object" &&
421
+ "error" in body &&
422
+ typeof (body as { error: unknown }).error === "string"
423
+ ) {
424
+ return (body as { error: string }).error;
425
+ }
426
+ return undefined;
427
+ };