@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.
- package/dist/bin.js +398 -46
- package/dist/bin.js.map +1 -1
- package/package.json +4 -4
- package/src/__tests__/connect-command.test.ts +13 -2
- package/src/__tests__/connect-discord-flow.test.ts +407 -0
- package/src/commands/connect.ts +157 -28
- package/src/lib/connect-discord-flow.ts +427 -0
- package/studio/assets/index-BCzbYwYS.css +1 -0
- package/studio/assets/index-DhnoxvTR.js +280 -0
- package/studio/index.html +2 -2
- package/studio/assets/index-Bf2wN1Hs.css +0 -1
- package/studio/assets/index-JPChDjmd.js +0 -265
package/src/commands/connect.ts
CHANGED
|
@@ -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> [
|
|
21
|
+
const usage = `hogsend connect <provider> [options] [--no-browser] [--json]
|
|
16
22
|
|
|
17
|
-
Connect this Hogsend instance to
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
|
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
|
+
};
|