@hogsend/cli 0.21.0 → 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 +409 -47
- 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 +167 -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/dist/bin.js
CHANGED
|
@@ -472,7 +472,7 @@ var campaignsCommand = {
|
|
|
472
472
|
|
|
473
473
|
// src/commands/connect.ts
|
|
474
474
|
import { parseArgs as parseArgs2 } from "util";
|
|
475
|
-
import { confirm, select, text } from "@clack/prompts";
|
|
475
|
+
import { confirm, password, select, text } from "@clack/prompts";
|
|
476
476
|
|
|
477
477
|
// src/lib/browser.ts
|
|
478
478
|
import { spawn } from "child_process";
|
|
@@ -491,6 +491,202 @@ function openBrowser(url) {
|
|
|
491
491
|
}
|
|
492
492
|
}
|
|
493
493
|
|
|
494
|
+
// src/lib/connect-discord-flow.ts
|
|
495
|
+
var ConnectDiscordError = class extends Error {
|
|
496
|
+
verdict;
|
|
497
|
+
hint;
|
|
498
|
+
constructor(verdict, message, hint) {
|
|
499
|
+
super(message);
|
|
500
|
+
this.name = "ConnectDiscordError";
|
|
501
|
+
this.verdict = verdict;
|
|
502
|
+
this.hint = hint;
|
|
503
|
+
}
|
|
504
|
+
};
|
|
505
|
+
var HINT_NOT_CONFIGURED = "Run `hogsend connect discord` interactively (in a terminal) to paste the four Discord portal values. To only read the current state, re-run with --status (which never prompts).";
|
|
506
|
+
var HINT_LOOPBACK = "Discord validates the interactions endpoint by PINGing it synchronously, so it must be publicly reachable. Run this against your DEPLOYED instance (point --url at it); the secrets are stored either way.";
|
|
507
|
+
function isLoopbackUrl(publicUrl) {
|
|
508
|
+
try {
|
|
509
|
+
const host = new URL(publicUrl).hostname.toLowerCase();
|
|
510
|
+
return host === "localhost" || host === "127.0.0.1" || host === "0.0.0.0" || host === "[::1]" || host === "::1" || host.endsWith(".localhost");
|
|
511
|
+
} catch {
|
|
512
|
+
return false;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
var errMsg = (err) => err instanceof Error ? err.message : String(err);
|
|
516
|
+
function portalSteps(info) {
|
|
517
|
+
return [
|
|
518
|
+
"One-time Discord developer portal setup:",
|
|
519
|
+
"",
|
|
520
|
+
" 1. https://discord.com/developers/applications -> New Application",
|
|
521
|
+
" 2. Bot tab -> Reset Token -> copy the BOT TOKEN. Enable the privileged",
|
|
522
|
+
" gateway intents: SERVER MEMBERS, MESSAGE CONTENT, PRESENCE.",
|
|
523
|
+
" 3. OAuth2 tab -> copy the CLIENT ID (= application id) and CLIENT",
|
|
524
|
+
" SECRET. Add this exact redirect URL:",
|
|
525
|
+
` ${info.redirectUri}`,
|
|
526
|
+
" 4. General Information tab -> copy the PUBLIC KEY.",
|
|
527
|
+
"",
|
|
528
|
+
"The interactions endpoint is wired for you (you do NOT paste it):",
|
|
529
|
+
` ${info.interactionsUrl}`
|
|
530
|
+
].join("\n");
|
|
531
|
+
}
|
|
532
|
+
async function runConnectDiscord(deps, opts) {
|
|
533
|
+
const base = deps.http.cfg.baseUrl;
|
|
534
|
+
const info = await deps.out.step(
|
|
535
|
+
`GET ${base}/v1/admin/connectors/discord/connect-info`,
|
|
536
|
+
() => deps.http.get(
|
|
537
|
+
"/v1/admin/connectors/discord/connect-info"
|
|
538
|
+
)
|
|
539
|
+
);
|
|
540
|
+
if (opts.statusOnly) {
|
|
541
|
+
deps.out.note(
|
|
542
|
+
[
|
|
543
|
+
"Discord connection status",
|
|
544
|
+
` instance ${base}`,
|
|
545
|
+
` secrets stored ${info.credentialStored ? "yes" : "no"}`,
|
|
546
|
+
` interactions ${info.botInstalled ? "wired" : "not wired"}`,
|
|
547
|
+
` guild id ${info.guildId ?? "(not yet captured)"}`,
|
|
548
|
+
` ingress secret ${info.ingressSecretConfigured ? "set" : "unset"}`,
|
|
549
|
+
` install url ${info.installUrl ?? "(stored secrets first)"}`
|
|
550
|
+
].join("\n")
|
|
551
|
+
);
|
|
552
|
+
return {
|
|
553
|
+
verdict: info.botInstalled ? "connected" : "secrets_stored_not_wired",
|
|
554
|
+
providerId: "discord",
|
|
555
|
+
instance: base,
|
|
556
|
+
secretsStored: info.credentialStored,
|
|
557
|
+
wired: info.botInstalled,
|
|
558
|
+
// The SERVER-MINTED install URL (carries a valid signed state). Null until
|
|
559
|
+
// the secrets are stored — there's nothing for the operator to open yet.
|
|
560
|
+
botInstallUrl: info.installUrl,
|
|
561
|
+
guildId: info.guildId
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
deps.out.note(portalSteps(info), "Discord portal");
|
|
565
|
+
if (!deps.interactive) {
|
|
566
|
+
throw new ConnectDiscordError(
|
|
567
|
+
"not_configured",
|
|
568
|
+
"Discord connect needs the four portal values pasted interactively",
|
|
569
|
+
HINT_NOT_CONFIGURED
|
|
570
|
+
);
|
|
571
|
+
}
|
|
572
|
+
const secrets = await deps.promptSecrets();
|
|
573
|
+
if (!secrets.appId.trim() || !secrets.publicKey.trim() || !secrets.botToken.trim() || !secrets.clientSecret.trim()) {
|
|
574
|
+
throw new ConnectDiscordError(
|
|
575
|
+
"paste_aborted",
|
|
576
|
+
"all four values (application id, public key, bot token, client secret) are required"
|
|
577
|
+
);
|
|
578
|
+
}
|
|
579
|
+
try {
|
|
580
|
+
await deps.out.step(
|
|
581
|
+
`PUT ${base}/v1/admin/connectors/discord/secrets`,
|
|
582
|
+
() => deps.http.put("/v1/admin/connectors/discord/secrets", {
|
|
583
|
+
appId: secrets.appId.trim(),
|
|
584
|
+
publicKey: secrets.publicKey.trim(),
|
|
585
|
+
botToken: secrets.botToken.trim(),
|
|
586
|
+
clientSecret: secrets.clientSecret.trim()
|
|
587
|
+
})
|
|
588
|
+
);
|
|
589
|
+
} catch (err) {
|
|
590
|
+
throw new ConnectDiscordError("store_failed", errMsg(err));
|
|
591
|
+
}
|
|
592
|
+
const stored = await deps.out.step(
|
|
593
|
+
"Reading the server-minted install URL",
|
|
594
|
+
() => deps.http.get(
|
|
595
|
+
"/v1/admin/connectors/discord/connect-info"
|
|
596
|
+
)
|
|
597
|
+
);
|
|
598
|
+
const botInstallUrl = stored.installUrl;
|
|
599
|
+
if (isLoopbackUrl(info.apiPublicUrl)) {
|
|
600
|
+
deps.out.note(
|
|
601
|
+
`Secrets stored, but this instance's API_PUBLIC_URL is ${info.apiPublicUrl} (a loopback address). Discord cannot reach the interactions endpoint, so wiring was skipped.
|
|
602
|
+
|
|
603
|
+
Wire it against your deployed instance:
|
|
604
|
+
|
|
605
|
+
hogsend connect discord --url https://your-instance`,
|
|
606
|
+
"Instance not publicly reachable"
|
|
607
|
+
);
|
|
608
|
+
return {
|
|
609
|
+
verdict: "secrets_stored_not_wired",
|
|
610
|
+
providerId: "discord",
|
|
611
|
+
instance: base,
|
|
612
|
+
secretsStored: true,
|
|
613
|
+
wired: false,
|
|
614
|
+
botInstallUrl,
|
|
615
|
+
guildId: info.guildId
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
try {
|
|
619
|
+
await deps.out.step(
|
|
620
|
+
`POST ${base}/v1/admin/connectors/discord/wire`,
|
|
621
|
+
() => deps.http.post("/v1/admin/connectors/discord/wire", {})
|
|
622
|
+
);
|
|
623
|
+
} catch (err) {
|
|
624
|
+
if (isHttpError(err) && err.status === 409 && httpErrorBody(err) === "api_public_url_unreachable") {
|
|
625
|
+
throw new ConnectDiscordError(
|
|
626
|
+
"api_public_url_unreachable",
|
|
627
|
+
`API_PUBLIC_URL is ${info.apiPublicUrl} \u2014 Discord cannot PING the interactions endpoint`,
|
|
628
|
+
HINT_LOOPBACK
|
|
629
|
+
);
|
|
630
|
+
}
|
|
631
|
+
throw new ConnectDiscordError("wire_failed", errMsg(err));
|
|
632
|
+
}
|
|
633
|
+
let opened = false;
|
|
634
|
+
if (botInstallUrl) {
|
|
635
|
+
deps.out.note(
|
|
636
|
+
["Add the bot to your server", ` ${botInstallUrl}`].join("\n")
|
|
637
|
+
);
|
|
638
|
+
opened = opts.noBrowser ? false : deps.openBrowser(botInstallUrl);
|
|
639
|
+
deps.out.log(
|
|
640
|
+
opened ? "Opening your browser to install the bot. Approve the install in your server, then this command finishes capturing the guild id." : "Open this URL in a browser to install the bot into your server:"
|
|
641
|
+
);
|
|
642
|
+
deps.out.log(` ${botInstallUrl}`);
|
|
643
|
+
} else {
|
|
644
|
+
deps.out.log(
|
|
645
|
+
"Secrets stored, but the server returned no install URL. Re-run `hogsend connect discord --status` to read it."
|
|
646
|
+
);
|
|
647
|
+
}
|
|
648
|
+
let guildId = stored.guildId;
|
|
649
|
+
if (!guildId && !opts.noBrowser) {
|
|
650
|
+
const proceed = await deps.confirm(
|
|
651
|
+
"Once you've approved the bot install in your browser, press Enter to capture the guild id (or skip and re-run with --status later)"
|
|
652
|
+
);
|
|
653
|
+
if (proceed) {
|
|
654
|
+
try {
|
|
655
|
+
const refreshed = await deps.out.step(
|
|
656
|
+
"Capturing the installed guild id",
|
|
657
|
+
() => deps.http.get(
|
|
658
|
+
"/v1/admin/connectors/discord/connect-info"
|
|
659
|
+
)
|
|
660
|
+
);
|
|
661
|
+
guildId = refreshed.guildId;
|
|
662
|
+
} catch {
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
if (!guildId) {
|
|
667
|
+
deps.out.log(
|
|
668
|
+
"Bot install not yet detected. Complete it in your browser, then run `hogsend connect discord --status` to capture the guild id."
|
|
669
|
+
);
|
|
670
|
+
}
|
|
671
|
+
return {
|
|
672
|
+
verdict: "connected",
|
|
673
|
+
providerId: "discord",
|
|
674
|
+
instance: base,
|
|
675
|
+
secretsStored: true,
|
|
676
|
+
wired: true,
|
|
677
|
+
botInstallUrl,
|
|
678
|
+
guildId
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
var httpErrorBody = (err) => {
|
|
682
|
+
if (!isHttpError(err)) return void 0;
|
|
683
|
+
const body = err.body;
|
|
684
|
+
if (body && typeof body === "object" && "error" in body && typeof body.error === "string") {
|
|
685
|
+
return body.error;
|
|
686
|
+
}
|
|
687
|
+
return void 0;
|
|
688
|
+
};
|
|
689
|
+
|
|
494
690
|
// src/lib/loopback.ts
|
|
495
691
|
import { createServer } from "http";
|
|
496
692
|
|
|
@@ -776,7 +972,7 @@ var ConnectError = class extends Error {
|
|
|
776
972
|
this.hint = hint;
|
|
777
973
|
}
|
|
778
974
|
};
|
|
779
|
-
var
|
|
975
|
+
var HINT_NOT_CONFIGURED2 = "Pass --posthog-host https://eu.posthog.com (or https://us.posthog.com, or your self-hosted app URL) to pick the region to authorize against. Alternatively set POSTHOG_HOST on the instance, redeploy, then re-run.";
|
|
780
976
|
var POSTHOG_EU_HOST = "https://eu.posthog.com";
|
|
781
977
|
var POSTHOG_US_HOST = "https://us.posthog.com";
|
|
782
978
|
var normalizeHost = (host) => host.replace(/\/+$/, "");
|
|
@@ -792,7 +988,7 @@ var SSH_NOTE = `The consent page must open in a browser on THIS machine \u2014 t
|
|
|
792
988
|
returns to 127.0.0.1 here. On a remote/SSH session this cannot complete: run
|
|
793
989
|
the command from your laptop instead and point --url at the instance (the CLI
|
|
794
990
|
never needs to run on the server).`;
|
|
795
|
-
function
|
|
991
|
+
function isLoopbackUrl2(publicUrl) {
|
|
796
992
|
try {
|
|
797
993
|
const host = new URL(publicUrl).hostname.toLowerCase();
|
|
798
994
|
return host === "localhost" || host === "127.0.0.1" || host === "0.0.0.0" || host === "[::1]" || host === "::1" || host.endsWith(".localhost");
|
|
@@ -807,8 +1003,8 @@ skipped (a destination pointing at localhost would be unreachable).
|
|
|
807
1003
|
Once deployed, wire the loop against the real instance:
|
|
808
1004
|
|
|
809
1005
|
hogsend connect posthog --provision-only --url https://your-instance`;
|
|
810
|
-
var
|
|
811
|
-
var
|
|
1006
|
+
var errMsg2 = (err) => err instanceof Error ? err.message : String(err);
|
|
1007
|
+
var httpErrorBody2 = (err) => {
|
|
812
1008
|
if (!isHttpError(err)) return void 0;
|
|
813
1009
|
const body = err.body;
|
|
814
1010
|
if (body && typeof body === "object" && "error" in body && typeof body.error === "string") {
|
|
@@ -840,7 +1036,7 @@ function fromLoopbackError(err) {
|
|
|
840
1036
|
}
|
|
841
1037
|
}
|
|
842
1038
|
async function runProvisionOnly(deps, info, base) {
|
|
843
|
-
if (
|
|
1039
|
+
if (isLoopbackUrl2(info.apiPublicUrl)) {
|
|
844
1040
|
deps.out.note(LOOPBACK_URL_NOTE, "Instance not publicly reachable");
|
|
845
1041
|
throw new ConnectError(
|
|
846
1042
|
"api_public_url_unreachable",
|
|
@@ -857,14 +1053,14 @@ async function runProvisionOnly(deps, info, base) {
|
|
|
857
1053
|
)
|
|
858
1054
|
);
|
|
859
1055
|
} catch (err) {
|
|
860
|
-
if (isHttpError(err) && err.status === 409 &&
|
|
1056
|
+
if (isHttpError(err) && err.status === 409 && httpErrorBody2(err) === "no_posthog_credential") {
|
|
861
1057
|
throw new ConnectError(
|
|
862
1058
|
"no_credential",
|
|
863
1059
|
"no PostHog credential is stored on this instance",
|
|
864
1060
|
"run `hogsend connect posthog` first"
|
|
865
1061
|
);
|
|
866
1062
|
}
|
|
867
|
-
throw new ConnectError("provision_failed",
|
|
1063
|
+
throw new ConnectError("provision_failed", errMsg2(err));
|
|
868
1064
|
}
|
|
869
1065
|
printProvisioned(deps.out, result);
|
|
870
1066
|
return {
|
|
@@ -911,7 +1107,7 @@ async function resolvePrivateHost(deps, info, opts) {
|
|
|
911
1107
|
throw new ConnectError(
|
|
912
1108
|
"not_configured",
|
|
913
1109
|
"this instance has no PostHog configuration",
|
|
914
|
-
|
|
1110
|
+
HINT_NOT_CONFIGURED2
|
|
915
1111
|
);
|
|
916
1112
|
}
|
|
917
1113
|
async function runConnectPosthog(deps, opts) {
|
|
@@ -1033,7 +1229,7 @@ async function runConnectPosthog(deps, opts) {
|
|
|
1033
1229
|
})
|
|
1034
1230
|
);
|
|
1035
1231
|
} catch (err) {
|
|
1036
|
-
throw new ConnectError("exchange_failed",
|
|
1232
|
+
throw new ConnectError("exchange_failed", errMsg2(err));
|
|
1037
1233
|
}
|
|
1038
1234
|
const expiresAt = new Date(
|
|
1039
1235
|
deps.now().getTime() + tokens.expires_in * 1e3
|
|
@@ -1059,7 +1255,7 @@ async function runConnectPosthog(deps, opts) {
|
|
|
1059
1255
|
})
|
|
1060
1256
|
);
|
|
1061
1257
|
} catch (err) {
|
|
1062
|
-
throw new ConnectError("store_failed",
|
|
1258
|
+
throw new ConnectError("store_failed", errMsg2(err));
|
|
1063
1259
|
}
|
|
1064
1260
|
const stored = {
|
|
1065
1261
|
providerId: "posthog",
|
|
@@ -1087,7 +1283,7 @@ async function runConnectPosthog(deps, opts) {
|
|
|
1087
1283
|
provision: { attempted: false, skipped: "no_provision_flag" }
|
|
1088
1284
|
};
|
|
1089
1285
|
}
|
|
1090
|
-
if (
|
|
1286
|
+
if (isLoopbackUrl2(info.apiPublicUrl)) {
|
|
1091
1287
|
deps.out.note(LOOPBACK_URL_NOTE, "Instance not publicly reachable");
|
|
1092
1288
|
return {
|
|
1093
1289
|
verdict: "connected_no_provision",
|
|
@@ -1116,7 +1312,7 @@ async function runConnectPosthog(deps, opts) {
|
|
|
1116
1312
|
}
|
|
1117
1313
|
};
|
|
1118
1314
|
} catch (err) {
|
|
1119
|
-
const message =
|
|
1315
|
+
const message = errMsg2(err);
|
|
1120
1316
|
deps.out.log(
|
|
1121
1317
|
`The credential is stored, but provisioning the event loop failed: ${message}. Re-run with: hogsend connect posthog --provision-only`
|
|
1122
1318
|
);
|
|
@@ -1139,20 +1335,26 @@ function bail(value) {
|
|
|
1139
1335
|
}
|
|
1140
1336
|
|
|
1141
1337
|
// src/commands/connect.ts
|
|
1142
|
-
var usage2 = `hogsend connect <provider> [
|
|
1338
|
+
var usage2 = `hogsend connect <provider> [options] [--no-browser] [--json]
|
|
1143
1339
|
|
|
1144
|
-
Connect this Hogsend instance to
|
|
1340
|
+
Connect this Hogsend instance to a provider. Providers:
|
|
1145
1341
|
|
|
1146
1342
|
posthog Authorize Hogsend against your PostHog region (PKCE, loopback
|
|
1147
1343
|
callback on 127.0.0.1), store the refresh token on the instance,
|
|
1148
1344
|
then provision the PostHog -> Hogsend event loop (a PostHog
|
|
1149
1345
|
destination posting to /v1/webhooks/posthog).
|
|
1150
1346
|
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1347
|
+
discord One-time Discord developer-portal setup: paste the four portal
|
|
1348
|
+
values (application id, public key, bot token, client secret),
|
|
1349
|
+
store them on the instance, wire the interactions endpoint
|
|
1350
|
+
(PATCH /applications/@me) server-side, then open the one-click
|
|
1351
|
+
bot-install link and capture the guild id.
|
|
1154
1352
|
|
|
1155
|
-
|
|
1353
|
+
For posthog, the browser consent must happen on THIS machine (the OAuth
|
|
1354
|
+
callback lands on 127.0.0.1). The target instance can be anywhere \u2014 point --url
|
|
1355
|
+
at it and run this command from your laptop, not from an SSH session.
|
|
1356
|
+
|
|
1357
|
+
posthog options:
|
|
1156
1358
|
--posthog-host PostHog app/private host to authorize against, e.g.
|
|
1157
1359
|
https://eu.posthog.com or https://us.posthog.com (NOT the
|
|
1158
1360
|
i. ingestion host). Required when the instance has no
|
|
@@ -1160,11 +1362,17 @@ Options:
|
|
|
1160
1362
|
--provision-only Skip OAuth; (re-)provision the event loop using the
|
|
1161
1363
|
already-stored credential.
|
|
1162
1364
|
--no-provision Stop after storing the credential.
|
|
1163
|
-
|
|
1365
|
+
|
|
1366
|
+
discord options:
|
|
1367
|
+
--status Read-only: report what's stored/wired and the captured
|
|
1368
|
+
guild id. Never prompts or stores anything.
|
|
1369
|
+
|
|
1370
|
+
Shared options:
|
|
1371
|
+
--no-browser Don't spawn a browser; just print the URL(s).
|
|
1164
1372
|
--url, --admin-key, --json, -h, --help Global flags as usual.
|
|
1165
1373
|
|
|
1166
|
-
Exit code: 0 when
|
|
1167
|
-
1 otherwise.`;
|
|
1374
|
+
Exit code: 0 when the connection is stored (even if a follow-up step was
|
|
1375
|
+
skipped), 1 otherwise.`;
|
|
1168
1376
|
async function run2(ctx) {
|
|
1169
1377
|
const { values: values2, positionals } = parseArgs2({
|
|
1170
1378
|
args: ctx.argv,
|
|
@@ -1175,6 +1383,7 @@ async function run2(ctx) {
|
|
|
1175
1383
|
"provision-only": { type: "boolean", default: false },
|
|
1176
1384
|
"no-provision": { type: "boolean", default: false },
|
|
1177
1385
|
"no-browser": { type: "boolean", default: false },
|
|
1386
|
+
status: { type: "boolean", default: false },
|
|
1178
1387
|
help: { type: "boolean", short: "h", default: false }
|
|
1179
1388
|
}
|
|
1180
1389
|
});
|
|
@@ -1186,22 +1395,27 @@ async function run2(ctx) {
|
|
|
1186
1395
|
if (!provider) {
|
|
1187
1396
|
ctx.out.fail("missing provider \u2014 try: hogsend connect posthog");
|
|
1188
1397
|
}
|
|
1398
|
+
if (provider === "discord") {
|
|
1399
|
+
await runDiscord(ctx, {
|
|
1400
|
+
noBrowser: Boolean(values2["no-browser"]),
|
|
1401
|
+
statusOnly: Boolean(values2.status)
|
|
1402
|
+
});
|
|
1403
|
+
return;
|
|
1404
|
+
}
|
|
1189
1405
|
if (provider !== "posthog") {
|
|
1190
|
-
ctx.out.fail(
|
|
1406
|
+
ctx.out.fail(
|
|
1407
|
+
`unknown provider "${provider}" \u2014 supported: posthog, discord`
|
|
1408
|
+
);
|
|
1191
1409
|
}
|
|
1192
1410
|
if (values2["provision-only"] && values2["no-provision"]) {
|
|
1193
1411
|
ctx.out.fail("--provision-only and --no-provision are mutually exclusive");
|
|
1194
1412
|
}
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
)
|
|
1202
|
-
);
|
|
1203
|
-
}
|
|
1204
|
-
} catch {
|
|
1413
|
+
if (isPlainHttpRemote(ctx.cfg.baseUrl)) {
|
|
1414
|
+
ctx.out.log(
|
|
1415
|
+
color.yellow(
|
|
1416
|
+
`warning: ${ctx.cfg.baseUrl} is plain http \u2014 OAuth tokens will be sent to it unencrypted; use https for remote instances.`
|
|
1417
|
+
)
|
|
1418
|
+
);
|
|
1205
1419
|
}
|
|
1206
1420
|
ctx.out.intro(`${color.bgMagenta(color.black(" hogsend "))} connect`);
|
|
1207
1421
|
const deps = {
|
|
@@ -1228,7 +1442,17 @@ async function run2(ctx) {
|
|
|
1228
1442
|
return bail(
|
|
1229
1443
|
await text({
|
|
1230
1444
|
message: "PostHog app/private host URL (e.g. https://posthog.example.com)",
|
|
1231
|
-
placeholder: "https://posthog.example.com"
|
|
1445
|
+
placeholder: "https://posthog.example.com",
|
|
1446
|
+
validate: (value) => {
|
|
1447
|
+
try {
|
|
1448
|
+
const url = new URL(value ?? "");
|
|
1449
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
1450
|
+
return "Enter a full URL, e.g. https://posthog.example.com";
|
|
1451
|
+
}
|
|
1452
|
+
} catch {
|
|
1453
|
+
return "Enter a full URL, e.g. https://posthog.example.com";
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1232
1456
|
})
|
|
1233
1457
|
);
|
|
1234
1458
|
},
|
|
@@ -1264,6 +1488,88 @@ async function run2(ctx) {
|
|
|
1264
1488
|
ctx.out.note(
|
|
1265
1489
|
error.hint ? `${error.message}
|
|
1266
1490
|
|
|
1491
|
+
${error.hint}` : error.message
|
|
1492
|
+
);
|
|
1493
|
+
ctx.out.outro(color.red(`connect: ${error.verdict}`));
|
|
1494
|
+
process.exit(1);
|
|
1495
|
+
}
|
|
1496
|
+
throw error;
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
function isPlainHttpRemote(baseUrl) {
|
|
1500
|
+
try {
|
|
1501
|
+
const target = new URL(baseUrl);
|
|
1502
|
+
return target.protocol === "http:" && target.hostname !== "localhost" && target.hostname !== "127.0.0.1";
|
|
1503
|
+
} catch {
|
|
1504
|
+
return false;
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
async function runDiscord(ctx, opts) {
|
|
1508
|
+
if (!opts.statusOnly && isPlainHttpRemote(ctx.cfg.baseUrl)) {
|
|
1509
|
+
ctx.out.fail(
|
|
1510
|
+
`${ctx.cfg.baseUrl} is plain http \u2014 refusing to send the Discord bot token + client secret to a remote instance unencrypted. Use https, or run --status (which sends no secrets).`
|
|
1511
|
+
);
|
|
1512
|
+
}
|
|
1513
|
+
ctx.out.intro(`${color.bgMagenta(color.black(" hogsend "))} connect`);
|
|
1514
|
+
const deps = {
|
|
1515
|
+
http: ctx.http,
|
|
1516
|
+
out: ctx.out,
|
|
1517
|
+
interactive: ctx.out.interactive,
|
|
1518
|
+
confirm: async (message) => bail(await confirm({ message })),
|
|
1519
|
+
openBrowser,
|
|
1520
|
+
promptSecrets: async () => {
|
|
1521
|
+
const appId = bail(
|
|
1522
|
+
await text({
|
|
1523
|
+
message: "Discord Application ID (OAuth2 -> Client ID)",
|
|
1524
|
+
placeholder: "1234567890123456789"
|
|
1525
|
+
})
|
|
1526
|
+
);
|
|
1527
|
+
const publicKey = bail(
|
|
1528
|
+
await text({
|
|
1529
|
+
message: "Public Key (General Information -> Public Key)",
|
|
1530
|
+
placeholder: "abc123..."
|
|
1531
|
+
})
|
|
1532
|
+
);
|
|
1533
|
+
const botToken = bail(
|
|
1534
|
+
await password({ message: "Bot Token (Bot -> Reset Token)" })
|
|
1535
|
+
);
|
|
1536
|
+
const clientSecret = bail(
|
|
1537
|
+
await password({
|
|
1538
|
+
message: "Client Secret (OAuth2 -> Client Secret)"
|
|
1539
|
+
})
|
|
1540
|
+
);
|
|
1541
|
+
return { appId, publicKey, botToken, clientSecret };
|
|
1542
|
+
},
|
|
1543
|
+
now: () => /* @__PURE__ */ new Date()
|
|
1544
|
+
};
|
|
1545
|
+
try {
|
|
1546
|
+
const result = await runConnectDiscord(deps, {
|
|
1547
|
+
noBrowser: opts.noBrowser,
|
|
1548
|
+
statusOnly: opts.statusOnly
|
|
1549
|
+
});
|
|
1550
|
+
if (ctx.json) {
|
|
1551
|
+
ctx.out.json({ ok: true, ...result });
|
|
1552
|
+
return;
|
|
1553
|
+
}
|
|
1554
|
+
ctx.out.outro(
|
|
1555
|
+
color.green(
|
|
1556
|
+
`connect: discord ${result.verdict === "connected" ? "connected" : "secrets stored (interactions not wired)"}`
|
|
1557
|
+
)
|
|
1558
|
+
);
|
|
1559
|
+
} catch (error) {
|
|
1560
|
+
if (error instanceof ConnectDiscordError) {
|
|
1561
|
+
if (ctx.json) {
|
|
1562
|
+
ctx.out.json({
|
|
1563
|
+
ok: false,
|
|
1564
|
+
verdict: error.verdict,
|
|
1565
|
+
error: error.message,
|
|
1566
|
+
hint: error.hint
|
|
1567
|
+
});
|
|
1568
|
+
process.exit(1);
|
|
1569
|
+
}
|
|
1570
|
+
ctx.out.note(
|
|
1571
|
+
error.hint ? `${error.message}
|
|
1572
|
+
|
|
1267
1573
|
${error.hint}` : error.message
|
|
1268
1574
|
);
|
|
1269
1575
|
ctx.out.outro(color.red(`connect: ${error.verdict}`));
|
|
@@ -1274,7 +1580,7 @@ ${error.hint}` : error.message
|
|
|
1274
1580
|
}
|
|
1275
1581
|
var connectCommand = {
|
|
1276
1582
|
name: "connect",
|
|
1277
|
-
summary: "Connect
|
|
1583
|
+
summary: "Connect a provider (posthog OAuth, discord bot)",
|
|
1278
1584
|
usage: usage2,
|
|
1279
1585
|
run: run2
|
|
1280
1586
|
};
|
|
@@ -4185,12 +4491,12 @@ async function runToken(ctx, argv) {
|
|
|
4185
4491
|
const flags = parseTokenFlags(argv);
|
|
4186
4492
|
const url = flags.url ?? (ctx.cfg.urlExplicit ? ctx.cfg.baseUrl : void 0) ?? process.env.HATCHET_URL;
|
|
4187
4493
|
const email = flags.email ?? process.env.HATCHET_ADMIN_EMAIL;
|
|
4188
|
-
const
|
|
4494
|
+
const password2 = flags.password ?? process.env.HATCHET_ADMIN_PASSWORD;
|
|
4189
4495
|
const missing = [];
|
|
4190
4496
|
if (!url) missing.push("--url (or HATCHET_URL)");
|
|
4191
4497
|
if (!email) missing.push("--email (or HATCHET_ADMIN_EMAIL)");
|
|
4192
|
-
if (!
|
|
4193
|
-
if (missing.length > 0 || !url || !email || !
|
|
4498
|
+
if (!password2) missing.push("--password (or HATCHET_ADMIN_PASSWORD)");
|
|
4499
|
+
if (missing.length > 0 || !url || !email || !password2) {
|
|
4194
4500
|
failToStderr(ctx, `missing ${missing.join(", ")}`);
|
|
4195
4501
|
}
|
|
4196
4502
|
const onProgress = ctx.json ? void 0 : (msg) => process.stderr.write(`${color.dim(msg)}
|
|
@@ -4200,7 +4506,7 @@ async function runToken(ctx, argv) {
|
|
|
4200
4506
|
const result = await mintHatchetToken({
|
|
4201
4507
|
url,
|
|
4202
4508
|
email,
|
|
4203
|
-
password,
|
|
4509
|
+
password: password2,
|
|
4204
4510
|
tenantSlug: flags.tenant,
|
|
4205
4511
|
tokenName: flags.tokenName,
|
|
4206
4512
|
onProgress
|
|
@@ -14114,6 +14420,7 @@ __export(schema_exports, {
|
|
|
14114
14420
|
bucketMemberships: () => bucketMemberships,
|
|
14115
14421
|
bucketMembershipsRelations: () => bucketMembershipsRelations,
|
|
14116
14422
|
campaigns: () => campaigns,
|
|
14423
|
+
connectorLinkCodes: () => connectorLinkCodes,
|
|
14117
14424
|
contactAliases: () => contactAliases,
|
|
14118
14425
|
contactAliasesRelations: () => contactAliasesRelations,
|
|
14119
14426
|
contacts: () => contacts,
|
|
@@ -14526,6 +14833,50 @@ var campaigns = pgTable(
|
|
|
14526
14833
|
]
|
|
14527
14834
|
);
|
|
14528
14835
|
|
|
14836
|
+
// ../db/src/schema/connector-link-codes.ts
|
|
14837
|
+
var connectorLinkCodes = pgTable(
|
|
14838
|
+
"connector_link_codes",
|
|
14839
|
+
{
|
|
14840
|
+
id: uuid("id").defaultRandom().primaryKey(),
|
|
14841
|
+
// The connector this code belongs to (e.g. "discord"). Scopes throttle
|
|
14842
|
+
// counts + lets one engine serve multiple connectors' link loops.
|
|
14843
|
+
connectorId: text2("connector_id").notNull(),
|
|
14844
|
+
// sha256(code) as lowercase hex — the lookup key. The plaintext code is
|
|
14845
|
+
// NEVER stored; it exists only in the emailed message.
|
|
14846
|
+
codeHash: text2("code_hash").notNull(),
|
|
14847
|
+
// The invoking platform user the code is BOUND to (Discord snowflake). A
|
|
14848
|
+
// redeem must present the SAME platform user id or it is rejected.
|
|
14849
|
+
platformUserId: text2("platform_user_id").notNull(),
|
|
14850
|
+
// The authoritative email the code was issued for (the resolution key the
|
|
14851
|
+
// redeem attaches to the platform identity). Stored normalized
|
|
14852
|
+
// (trim + toLowerCase) by the caller.
|
|
14853
|
+
targetEmail: text2("target_email").notNull(),
|
|
14854
|
+
// Absolute expiry. A redeem after this instant is rejected as expired.
|
|
14855
|
+
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
|
14856
|
+
// Single-use marker: NULL until redeemed, set to the redemption instant by
|
|
14857
|
+
// the atomic `UPDATE ... WHERE used_at IS NULL`. A second redeem misses.
|
|
14858
|
+
usedAt: timestamp("used_at", { withTimezone: true }),
|
|
14859
|
+
...timestamps
|
|
14860
|
+
},
|
|
14861
|
+
(table) => [
|
|
14862
|
+
// Redeem looks the row up by hash. Unique: a hash collision (or a duplicate
|
|
14863
|
+
// mint of the identical code) must never produce two redeemable rows.
|
|
14864
|
+
uniqueIndex("connector_link_codes_code_hash_idx").on(table.codeHash),
|
|
14865
|
+
// Serves the per-invoking-user throttle COUNT (connector + user + recency).
|
|
14866
|
+
index("connector_link_codes_throttle_user_idx").on(
|
|
14867
|
+
table.connectorId,
|
|
14868
|
+
table.platformUserId,
|
|
14869
|
+
table.createdAt
|
|
14870
|
+
),
|
|
14871
|
+
// Serves the per-target-email throttle COUNT (connector + email + recency).
|
|
14872
|
+
index("connector_link_codes_throttle_email_idx").on(
|
|
14873
|
+
table.connectorId,
|
|
14874
|
+
table.targetEmail,
|
|
14875
|
+
table.createdAt
|
|
14876
|
+
)
|
|
14877
|
+
]
|
|
14878
|
+
);
|
|
14879
|
+
|
|
14529
14880
|
// ../db/src/schema/contacts.ts
|
|
14530
14881
|
var contacts = pgTable(
|
|
14531
14882
|
"contacts",
|
|
@@ -14548,6 +14899,16 @@ var contacts = pgTable(
|
|
|
14548
14899
|
* index scoped to live, non-deleted rows.
|
|
14549
14900
|
*/
|
|
14550
14901
|
anonymousId: text2("anonymous_id"),
|
|
14902
|
+
/**
|
|
14903
|
+
* Nullable Discord user id (snowflake) attached to an email-keyed contact
|
|
14904
|
+
* when a member completes the per-member OAuth link. Like external_id it is
|
|
14905
|
+
* a RESOLVABLE identity key (a fourth `Kind`), NOT a property — but it is
|
|
14906
|
+
* NEVER the canonical text key (`external_id ?? anonymous_id ?? id`), so it
|
|
14907
|
+
* does not participate in the history re-point. Uniqueness is the
|
|
14908
|
+
* partial-unique live-row index below, identical to
|
|
14909
|
+
* contacts_external_id_unique_idx.
|
|
14910
|
+
*/
|
|
14911
|
+
discordId: text2("discord_id"),
|
|
14551
14912
|
/**
|
|
14552
14913
|
* Opportunistic IANA-timezone cache (e.g. "America/New_York"). Populated
|
|
14553
14914
|
* best-effort when a tz is resolved from PostHog person props. PostHog and
|
|
@@ -14573,7 +14934,8 @@ var contacts = pgTable(
|
|
|
14573
14934
|
// resolvable identity key. Emails are stored already-normalized (trim +
|
|
14574
14935
|
// toLowerCase), so lower() here is belt-and-suspenders.
|
|
14575
14936
|
uniqueIndex("contacts_email_unique_idx").on(sql`lower(email)`).where(sql`email IS NOT NULL AND deleted_at IS NULL`),
|
|
14576
|
-
uniqueIndex("contacts_anonymous_id_unique_idx").on(table.anonymousId).where(sql`anonymous_id IS NOT NULL AND deleted_at IS NULL`)
|
|
14937
|
+
uniqueIndex("contacts_anonymous_id_unique_idx").on(table.anonymousId).where(sql`anonymous_id IS NOT NULL AND deleted_at IS NULL`),
|
|
14938
|
+
uniqueIndex("contacts_discord_id_unique_idx").on(table.discordId).where(sql`discord_id IS NOT NULL AND deleted_at IS NULL`)
|
|
14577
14939
|
]
|
|
14578
14940
|
);
|
|
14579
14941
|
|
|
@@ -14584,7 +14946,7 @@ var contactAliases = pgTable(
|
|
|
14584
14946
|
id: uuid("id").defaultRandom().primaryKey(),
|
|
14585
14947
|
// The SURVIVOR a stale key resolves TO.
|
|
14586
14948
|
contactId: uuid("contact_id").notNull().references(() => contacts.id, { onDelete: "cascade" }),
|
|
14587
|
-
// 'email' | 'external' | 'anonymous'
|
|
14949
|
+
// 'email' | 'external' | 'anonymous' | 'discord'
|
|
14588
14950
|
aliasKind: text2("alias_kind").notNull(),
|
|
14589
14951
|
// The stale key value (the loser's old external_id / normalized email /
|
|
14590
14952
|
// anonymous_id).
|
|
@@ -15223,14 +15585,14 @@ var AdminAlreadyExistsError = class extends Error {
|
|
|
15223
15585
|
email;
|
|
15224
15586
|
};
|
|
15225
15587
|
async function createAdminUser(opts) {
|
|
15226
|
-
const { auth, email, password } = opts;
|
|
15588
|
+
const { auth, email, password: password2 } = opts;
|
|
15227
15589
|
const displayName = opts.name ?? email.split("@")[0] ?? email;
|
|
15228
15590
|
const ctx = await auth.$context;
|
|
15229
15591
|
const existing = await ctx.internalAdapter.findUserByEmail(email);
|
|
15230
15592
|
if (existing) {
|
|
15231
15593
|
throw new AdminAlreadyExistsError(email);
|
|
15232
15594
|
}
|
|
15233
|
-
const hashed = await ctx.password.hash(
|
|
15595
|
+
const hashed = await ctx.password.hash(password2);
|
|
15234
15596
|
const created = await ctx.internalAdapter.createUser({
|
|
15235
15597
|
email,
|
|
15236
15598
|
name: displayName,
|
|
@@ -15272,9 +15634,9 @@ function createAdminRecovery(opts) {
|
|
|
15272
15634
|
baseURL: opts.baseURL ?? "http://localhost:3002"
|
|
15273
15635
|
});
|
|
15274
15636
|
return {
|
|
15275
|
-
async create({ email, password, name }) {
|
|
15637
|
+
async create({ email, password: password2, name }) {
|
|
15276
15638
|
try {
|
|
15277
|
-
return await createAdminUser({ auth, email, name, password });
|
|
15639
|
+
return await createAdminUser({ auth, email, name, password: password2 });
|
|
15278
15640
|
} catch (err) {
|
|
15279
15641
|
if (err instanceof AdminAlreadyExistsError) {
|
|
15280
15642
|
throw new Error(err.message);
|
|
@@ -15285,7 +15647,7 @@ function createAdminRecovery(opts) {
|
|
|
15285
15647
|
throw err;
|
|
15286
15648
|
}
|
|
15287
15649
|
},
|
|
15288
|
-
async reset({ email, password, revokeSessions }) {
|
|
15650
|
+
async reset({ email, password: password2, revokeSessions }) {
|
|
15289
15651
|
const ctx = await auth.$context;
|
|
15290
15652
|
const found = await ctx.internalAdapter.findUserByEmail(email, {
|
|
15291
15653
|
includeAccounts: true
|
|
@@ -15295,7 +15657,7 @@ function createAdminRecovery(opts) {
|
|
|
15295
15657
|
`No admin with email "${email}". Use \`hogsend studio admin create\` to create one.`
|
|
15296
15658
|
);
|
|
15297
15659
|
}
|
|
15298
|
-
const hashed = await ctx.password.hash(
|
|
15660
|
+
const hashed = await ctx.password.hash(password2);
|
|
15299
15661
|
const hasCredential = found.accounts?.some(
|
|
15300
15662
|
(a) => a.providerId === "credential"
|
|
15301
15663
|
);
|