@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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hogsend/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"tsup": "^8.5.1",
|
|
35
35
|
"tsx": "^4.22.4",
|
|
36
36
|
"vitest": "^4.1.7",
|
|
37
|
-
"@hogsend/studio": "^0.
|
|
37
|
+
"@hogsend/studio": "^0.22.0",
|
|
38
38
|
"@repo/typescript-config": "0.0.0"
|
|
39
39
|
},
|
|
40
40
|
"engines": {
|
|
@@ -44,8 +44,8 @@
|
|
|
44
44
|
"@clack/prompts": "^1.5.0",
|
|
45
45
|
"better-auth": "^1.6.11",
|
|
46
46
|
"picocolors": "^1.1.1",
|
|
47
|
-
"@hogsend/db": "^0.
|
|
48
|
-
"@hogsend/engine": "^0.
|
|
47
|
+
"@hogsend/db": "^0.22.0",
|
|
48
|
+
"@hogsend/engine": "^0.22.0"
|
|
49
49
|
},
|
|
50
50
|
"scripts": {
|
|
51
51
|
"prebuild": "node scripts/bundle-studio.mjs",
|
|
@@ -82,10 +82,21 @@ describe("hogsend connect — argv mapping", () => {
|
|
|
82
82
|
await expect(connectCommand.run(ctx)).rejects.toThrow(/missing provider/i);
|
|
83
83
|
});
|
|
84
84
|
|
|
85
|
-
it("fails on an unknown provider", async () => {
|
|
85
|
+
it("fails on an unknown provider and lists both supported", async () => {
|
|
86
86
|
const { ctx } = makeCtx(["stripe"]);
|
|
87
87
|
await expect(connectCommand.run(ctx)).rejects.toThrow(
|
|
88
|
-
/unknown provider "stripe"/,
|
|
88
|
+
/unknown provider "stripe" — supported: posthog, discord/,
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("refuses to PUT discord secrets over plain http to a remote instance", async () => {
|
|
93
|
+
const { ctx } = makeCtx(["discord"]);
|
|
94
|
+
// Default makeCtx baseUrl is http://localhost:3002 (loopback) — point it
|
|
95
|
+
// at a plain-http REMOTE host to trip the secret-PUT refusal.
|
|
96
|
+
(ctx.cfg as { baseUrl: string }).baseUrl = "http://remote.example.com";
|
|
97
|
+
(ctx.http.cfg as { baseUrl: string }).baseUrl = "http://remote.example.com";
|
|
98
|
+
await expect(connectCommand.run(ctx)).rejects.toThrow(
|
|
99
|
+
/refusing to send the Discord bot token/i,
|
|
89
100
|
);
|
|
90
101
|
});
|
|
91
102
|
|
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
buildBotInstallUrl,
|
|
4
|
+
ConnectDiscordError,
|
|
5
|
+
type ConnectDiscordFlowDeps,
|
|
6
|
+
type ConnectDiscordFlowOptions,
|
|
7
|
+
type DiscordConnectInfoResponse,
|
|
8
|
+
type DiscordSecrets,
|
|
9
|
+
runConnectDiscord,
|
|
10
|
+
} from "../lib/connect-discord-flow.js";
|
|
11
|
+
import type { AdminClient, HttpError } from "../lib/http.js";
|
|
12
|
+
import type { Output } from "../lib/output.js";
|
|
13
|
+
|
|
14
|
+
// Everything is dependency-injected — no vi.mock needed (mirrors
|
|
15
|
+
// connect-flow.test.ts's harness style).
|
|
16
|
+
|
|
17
|
+
const BASE_URL = "https://api.example.com";
|
|
18
|
+
const NOW = new Date("2026-06-13T18:00:00Z");
|
|
19
|
+
|
|
20
|
+
const SECRETS: DiscordSecrets = {
|
|
21
|
+
appId: "1234567890",
|
|
22
|
+
publicKey: "pubkey_fixture_hex",
|
|
23
|
+
botToken: "bot_token_fixture_secret",
|
|
24
|
+
clientSecret: "client_fixture_secret",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// The server-minted install URL the connect-info read returns once the secrets
|
|
28
|
+
// are stored — carries a signed CSRF `state` the CLI never builds itself.
|
|
29
|
+
const SERVER_INSTALL_URL =
|
|
30
|
+
"https://discord.com/oauth2/authorize?client_id=1234567890&response_type=" +
|
|
31
|
+
"code&scope=bot+applications.commands&redirect_uri=" +
|
|
32
|
+
encodeURIComponent(`${BASE_URL}/v1/connectors/discord/oauth/callback`) +
|
|
33
|
+
"&state=server-signed-state";
|
|
34
|
+
|
|
35
|
+
function connectInfo(
|
|
36
|
+
over: Partial<DiscordConnectInfoResponse> = {},
|
|
37
|
+
): DiscordConnectInfoResponse {
|
|
38
|
+
return {
|
|
39
|
+
providerId: "discord",
|
|
40
|
+
apiPublicUrl: BASE_URL,
|
|
41
|
+
redirectUri: `${BASE_URL}/v1/connectors/discord/oauth/callback`,
|
|
42
|
+
interactionsUrl: `${BASE_URL}/v1/connectors/discord/interactions`,
|
|
43
|
+
ingressSecretConfigured: true,
|
|
44
|
+
credentialStored: false,
|
|
45
|
+
guildId: null,
|
|
46
|
+
botInstalled: false,
|
|
47
|
+
installUrl: null,
|
|
48
|
+
...over,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function makeHttpError(status: number, body: unknown): HttpError {
|
|
53
|
+
const err = new Error(`request failed with status ${status}`) as HttpError;
|
|
54
|
+
err.name = "HttpError";
|
|
55
|
+
err.status = status;
|
|
56
|
+
err.body = body;
|
|
57
|
+
return err;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface Harness {
|
|
61
|
+
deps: ConnectDiscordFlowDeps;
|
|
62
|
+
/** Every string handed to ANY out.* sink. */
|
|
63
|
+
sink: string[];
|
|
64
|
+
calls: {
|
|
65
|
+
get: string[];
|
|
66
|
+
put: Array<{ path: string; body: unknown }>;
|
|
67
|
+
post: Array<{ path: string; body: unknown }>;
|
|
68
|
+
browserUrls: string[];
|
|
69
|
+
promptCount: number;
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function makeHarness(opts: {
|
|
74
|
+
info?: DiscordConnectInfoResponse;
|
|
75
|
+
/** Second GET (poll) override — falls back to the first `info`. */
|
|
76
|
+
pollInfo?: DiscordConnectInfoResponse;
|
|
77
|
+
secrets?: DiscordSecrets;
|
|
78
|
+
putError?: Error;
|
|
79
|
+
postResult?: unknown | Error;
|
|
80
|
+
interactive?: boolean;
|
|
81
|
+
confirmAnswer?: boolean;
|
|
82
|
+
}): Harness {
|
|
83
|
+
const sink: string[] = [];
|
|
84
|
+
const calls: Harness["calls"] = {
|
|
85
|
+
get: [],
|
|
86
|
+
put: [],
|
|
87
|
+
post: [],
|
|
88
|
+
browserUrls: [],
|
|
89
|
+
promptCount: 0,
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const out: Output = {
|
|
93
|
+
interactive: opts.interactive ?? false,
|
|
94
|
+
isJson: false,
|
|
95
|
+
intro: (title) => {
|
|
96
|
+
sink.push(title);
|
|
97
|
+
},
|
|
98
|
+
step: async (label, fn) => {
|
|
99
|
+
sink.push(label);
|
|
100
|
+
return fn();
|
|
101
|
+
},
|
|
102
|
+
note: (body, title) => {
|
|
103
|
+
sink.push(title ? `${title}\n${body}` : body);
|
|
104
|
+
},
|
|
105
|
+
table: () => {},
|
|
106
|
+
kv: (obj, title) => {
|
|
107
|
+
sink.push(`${title ?? ""}${JSON.stringify(obj)}`);
|
|
108
|
+
},
|
|
109
|
+
log: (msg) => {
|
|
110
|
+
sink.push(msg);
|
|
111
|
+
},
|
|
112
|
+
json: (payload) => {
|
|
113
|
+
sink.push(JSON.stringify(payload));
|
|
114
|
+
},
|
|
115
|
+
outro: (msg) => {
|
|
116
|
+
sink.push(msg);
|
|
117
|
+
},
|
|
118
|
+
fail: (message): never => {
|
|
119
|
+
throw new Error(`fail: ${message}`);
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
let getCount = 0;
|
|
124
|
+
const http = {
|
|
125
|
+
cfg: { baseUrl: BASE_URL } as AdminClient["cfg"],
|
|
126
|
+
get: async (path: string) => {
|
|
127
|
+
calls.get.push(path);
|
|
128
|
+
getCount += 1;
|
|
129
|
+
const first = opts.info ?? connectInfo();
|
|
130
|
+
const result = getCount === 1 ? first : (opts.pollInfo ?? first);
|
|
131
|
+
return result as never;
|
|
132
|
+
},
|
|
133
|
+
put: async (path: string, body: unknown) => {
|
|
134
|
+
calls.put.push({ path, body });
|
|
135
|
+
if (opts.putError) throw opts.putError;
|
|
136
|
+
return {} as never;
|
|
137
|
+
},
|
|
138
|
+
post: async (path: string, body: unknown) => {
|
|
139
|
+
calls.post.push({ path, body });
|
|
140
|
+
const result = opts.postResult ?? {};
|
|
141
|
+
if (result instanceof Error) throw result;
|
|
142
|
+
return result as never;
|
|
143
|
+
},
|
|
144
|
+
patch: async () => {
|
|
145
|
+
throw new Error("unexpected PATCH");
|
|
146
|
+
},
|
|
147
|
+
del: async () => {
|
|
148
|
+
throw new Error("unexpected DELETE");
|
|
149
|
+
},
|
|
150
|
+
} as AdminClient;
|
|
151
|
+
|
|
152
|
+
const deps: ConnectDiscordFlowDeps = {
|
|
153
|
+
http,
|
|
154
|
+
out,
|
|
155
|
+
interactive: opts.interactive ?? false,
|
|
156
|
+
confirm: async () => opts.confirmAnswer ?? true,
|
|
157
|
+
openBrowser: (url) => {
|
|
158
|
+
calls.browserUrls.push(url);
|
|
159
|
+
return true;
|
|
160
|
+
},
|
|
161
|
+
promptSecrets: async () => {
|
|
162
|
+
calls.promptCount += 1;
|
|
163
|
+
return opts.secrets ?? SECRETS;
|
|
164
|
+
},
|
|
165
|
+
now: () => NOW,
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
return { deps, sink, calls };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const FLOW_DEFAULTS: ConnectDiscordFlowOptions = {
|
|
172
|
+
noBrowser: false,
|
|
173
|
+
statusOnly: false,
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const expectError = async (promise: Promise<unknown>, verdict: string) => {
|
|
177
|
+
await expect(promise).rejects.toSatisfy((err: unknown) => {
|
|
178
|
+
expect(err).toBeInstanceOf(ConnectDiscordError);
|
|
179
|
+
expect((err as ConnectDiscordError).verdict).toBe(verdict);
|
|
180
|
+
return true;
|
|
181
|
+
});
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
describe("buildBotInstallUrl", () => {
|
|
185
|
+
it("carries the app id, scopes, redirect, and CSRF state", () => {
|
|
186
|
+
const url = new URL(
|
|
187
|
+
buildBotInstallUrl({
|
|
188
|
+
applicationId: "9999",
|
|
189
|
+
redirectUri: "https://x.example/cb",
|
|
190
|
+
state: "st4te",
|
|
191
|
+
}),
|
|
192
|
+
);
|
|
193
|
+
expect(url.origin + url.pathname).toBe(
|
|
194
|
+
"https://discord.com/oauth2/authorize",
|
|
195
|
+
);
|
|
196
|
+
expect(url.searchParams.get("client_id")).toBe("9999");
|
|
197
|
+
expect(url.searchParams.get("response_type")).toBe("code");
|
|
198
|
+
expect(url.searchParams.get("scope")).toBe("bot applications.commands");
|
|
199
|
+
expect(url.searchParams.get("redirect_uri")).toBe("https://x.example/cb");
|
|
200
|
+
expect(url.searchParams.get("state")).toBe("st4te");
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
describe("runConnectDiscord — happy path", () => {
|
|
205
|
+
it("stores the four secrets, wires, opens the SERVER install link, reports connected", async () => {
|
|
206
|
+
const h = makeHarness({
|
|
207
|
+
interactive: true,
|
|
208
|
+
// The post-store connect-info read returns the server-minted install URL
|
|
209
|
+
// AND (in this fixture) the captured guild id.
|
|
210
|
+
pollInfo: connectInfo({
|
|
211
|
+
guildId: "guild-42",
|
|
212
|
+
botInstalled: true,
|
|
213
|
+
installUrl: SERVER_INSTALL_URL,
|
|
214
|
+
}),
|
|
215
|
+
});
|
|
216
|
+
const result = await runConnectDiscord(h.deps, FLOW_DEFAULTS);
|
|
217
|
+
|
|
218
|
+
expect(result.verdict).toBe("connected");
|
|
219
|
+
expect(result.instance).toBe(BASE_URL);
|
|
220
|
+
expect(result.secretsStored).toBe(true);
|
|
221
|
+
expect(result.wired).toBe(true);
|
|
222
|
+
expect(result.guildId).toBe("guild-42");
|
|
223
|
+
// The result carries the SERVER-MINTED install URL, not a client-built one.
|
|
224
|
+
expect(result.botInstallUrl).toBe(SERVER_INSTALL_URL);
|
|
225
|
+
|
|
226
|
+
// The four pasted values PUT exactly, trimmed.
|
|
227
|
+
expect(h.calls.put).toEqual([
|
|
228
|
+
{
|
|
229
|
+
path: "/v1/admin/connectors/discord/secrets",
|
|
230
|
+
body: {
|
|
231
|
+
appId: "1234567890",
|
|
232
|
+
publicKey: "pubkey_fixture_hex",
|
|
233
|
+
botToken: "bot_token_fixture_secret",
|
|
234
|
+
clientSecret: "client_fixture_secret",
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
]);
|
|
238
|
+
|
|
239
|
+
// Wire POSTed once with {}.
|
|
240
|
+
expect(h.calls.post).toEqual([
|
|
241
|
+
{ path: "/v1/admin/connectors/discord/wire", body: {} },
|
|
242
|
+
]);
|
|
243
|
+
|
|
244
|
+
// The CLI re-reads connect-info after storing to pick up the server URL.
|
|
245
|
+
expect(h.calls.get).toEqual([
|
|
246
|
+
"/v1/admin/connectors/discord/connect-info",
|
|
247
|
+
"/v1/admin/connectors/discord/connect-info",
|
|
248
|
+
]);
|
|
249
|
+
|
|
250
|
+
// The SERVER-MINTED install URL is opened verbatim — the CLI never builds
|
|
251
|
+
// its own state (the server-signed state is the only one the callback
|
|
252
|
+
// accepts).
|
|
253
|
+
expect(h.calls.browserUrls).toEqual([SERVER_INSTALL_URL]);
|
|
254
|
+
const url = new URL(h.calls.browserUrls[0] ?? "");
|
|
255
|
+
expect(url.searchParams.get("state")).toBe("server-signed-state");
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("never leaks the bot token / client secret to the output sink", async () => {
|
|
259
|
+
const h = makeHarness({
|
|
260
|
+
interactive: true,
|
|
261
|
+
pollInfo: connectInfo({
|
|
262
|
+
botInstalled: true,
|
|
263
|
+
guildId: "g",
|
|
264
|
+
installUrl: SERVER_INSTALL_URL,
|
|
265
|
+
}),
|
|
266
|
+
});
|
|
267
|
+
await runConnectDiscord(h.deps, FLOW_DEFAULTS);
|
|
268
|
+
|
|
269
|
+
const all = h.sink.join("\n");
|
|
270
|
+
expect(all).not.toContain("bot_token_fixture_secret");
|
|
271
|
+
expect(all).not.toContain("client_fixture_secret");
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
describe("runConnectDiscord — failure verdicts", () => {
|
|
276
|
+
it("not_configured when non-interactive (can't safely prompt for secrets)", async () => {
|
|
277
|
+
const h = makeHarness({ interactive: false });
|
|
278
|
+
await expectError(
|
|
279
|
+
runConnectDiscord(h.deps, FLOW_DEFAULTS),
|
|
280
|
+
"not_configured",
|
|
281
|
+
);
|
|
282
|
+
// Never prompted, never PUT.
|
|
283
|
+
expect(h.calls.promptCount).toBe(0);
|
|
284
|
+
expect(h.calls.put).toHaveLength(0);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("paste_aborted when a required value is blank", async () => {
|
|
288
|
+
const h = makeHarness({
|
|
289
|
+
interactive: true,
|
|
290
|
+
secrets: { ...SECRETS, botToken: " " },
|
|
291
|
+
});
|
|
292
|
+
await expectError(
|
|
293
|
+
runConnectDiscord(h.deps, FLOW_DEFAULTS),
|
|
294
|
+
"paste_aborted",
|
|
295
|
+
);
|
|
296
|
+
expect(h.calls.put).toHaveLength(0);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it("store_failed when the secrets PUT fails — no wire", async () => {
|
|
300
|
+
const h = makeHarness({
|
|
301
|
+
interactive: true,
|
|
302
|
+
putError: makeHttpError(500, { error: "boom" }),
|
|
303
|
+
});
|
|
304
|
+
await expectError(runConnectDiscord(h.deps, FLOW_DEFAULTS), "store_failed");
|
|
305
|
+
expect(h.calls.post).toHaveLength(0);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it("wire_failed when the wire POST fails", async () => {
|
|
309
|
+
const h = makeHarness({
|
|
310
|
+
interactive: true,
|
|
311
|
+
postResult: makeHttpError(500, { error: "patch failed" }),
|
|
312
|
+
});
|
|
313
|
+
await expectError(runConnectDiscord(h.deps, FLOW_DEFAULTS), "wire_failed");
|
|
314
|
+
// Secrets were stored before the wire attempt.
|
|
315
|
+
expect(h.calls.put).toHaveLength(1);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it("maps a 409 api_public_url_unreachable from the wire POST", async () => {
|
|
319
|
+
const h = makeHarness({
|
|
320
|
+
interactive: true,
|
|
321
|
+
postResult: makeHttpError(409, { error: "api_public_url_unreachable" }),
|
|
322
|
+
});
|
|
323
|
+
await expectError(
|
|
324
|
+
runConnectDiscord(h.deps, FLOW_DEFAULTS),
|
|
325
|
+
"api_public_url_unreachable",
|
|
326
|
+
);
|
|
327
|
+
});
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
describe("runConnectDiscord — loopback defer", () => {
|
|
331
|
+
it("stores secrets but skips wiring when API_PUBLIC_URL is loopback", async () => {
|
|
332
|
+
const h = makeHarness({
|
|
333
|
+
interactive: true,
|
|
334
|
+
info: connectInfo({ apiPublicUrl: "http://localhost:3002" }),
|
|
335
|
+
});
|
|
336
|
+
const result = await runConnectDiscord(h.deps, FLOW_DEFAULTS);
|
|
337
|
+
|
|
338
|
+
expect(result.verdict).toBe("secrets_stored_not_wired");
|
|
339
|
+
expect(result.secretsStored).toBe(true);
|
|
340
|
+
expect(result.wired).toBe(false);
|
|
341
|
+
expect(h.calls.put).toHaveLength(1);
|
|
342
|
+
expect(h.calls.post).toHaveLength(0);
|
|
343
|
+
|
|
344
|
+
const note = h.sink.find((s) => s.includes("loopback"));
|
|
345
|
+
expect(note).toBeDefined();
|
|
346
|
+
expect(note).toContain("--url");
|
|
347
|
+
});
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
describe("runConnectDiscord — --status", () => {
|
|
351
|
+
const STATUS: ConnectDiscordFlowOptions = {
|
|
352
|
+
...FLOW_DEFAULTS,
|
|
353
|
+
statusOnly: true,
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
it("reads connect-info and reports without prompting or PUTting", async () => {
|
|
357
|
+
const h = makeHarness({
|
|
358
|
+
info: connectInfo({
|
|
359
|
+
credentialStored: true,
|
|
360
|
+
botInstalled: true,
|
|
361
|
+
guildId: "guild-7",
|
|
362
|
+
}),
|
|
363
|
+
});
|
|
364
|
+
const result = await runConnectDiscord(h.deps, STATUS);
|
|
365
|
+
|
|
366
|
+
expect(result.verdict).toBe("connected");
|
|
367
|
+
expect(result.wired).toBe(true);
|
|
368
|
+
expect(result.guildId).toBe("guild-7");
|
|
369
|
+
expect(h.calls.get).toEqual(["/v1/admin/connectors/discord/connect-info"]);
|
|
370
|
+
expect(h.calls.promptCount).toBe(0);
|
|
371
|
+
expect(h.calls.put).toHaveLength(0);
|
|
372
|
+
expect(h.calls.post).toHaveLength(0);
|
|
373
|
+
expect(h.calls.browserUrls).toHaveLength(0);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it("reports secrets_stored_not_wired when not yet wired", async () => {
|
|
377
|
+
const h = makeHarness({
|
|
378
|
+
info: connectInfo({ credentialStored: true, botInstalled: false }),
|
|
379
|
+
});
|
|
380
|
+
const result = await runConnectDiscord(h.deps, STATUS);
|
|
381
|
+
expect(result.verdict).toBe("secrets_stored_not_wired");
|
|
382
|
+
expect(result.wired).toBe(false);
|
|
383
|
+
});
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
describe("runConnectDiscord — --no-browser", () => {
|
|
387
|
+
it("does not open a browser and prints the SERVER install URL", async () => {
|
|
388
|
+
const h = makeHarness({
|
|
389
|
+
interactive: true,
|
|
390
|
+
pollInfo: connectInfo({
|
|
391
|
+
botInstalled: true,
|
|
392
|
+
guildId: "g",
|
|
393
|
+
installUrl: SERVER_INSTALL_URL,
|
|
394
|
+
}),
|
|
395
|
+
});
|
|
396
|
+
const result = await runConnectDiscord(h.deps, {
|
|
397
|
+
...FLOW_DEFAULTS,
|
|
398
|
+
noBrowser: true,
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
expect(result.verdict).toBe("connected");
|
|
402
|
+
expect(result.botInstallUrl).toBe(SERVER_INSTALL_URL);
|
|
403
|
+
expect(h.calls.browserUrls).toHaveLength(0);
|
|
404
|
+
const printed = h.sink.find((s) => s.includes("discord.com/oauth2"));
|
|
405
|
+
expect(printed).toBeDefined();
|
|
406
|
+
});
|
|
407
|
+
});
|