@hogsend/cli 0.11.0 → 0.12.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.
Files changed (32) hide show
  1. package/dist/bin.js +1985 -807
  2. package/dist/bin.js.map +1 -1
  3. package/package.json +4 -4
  4. package/skills/hogsend-integrate/SKILL.md +198 -0
  5. package/skills/hogsend-integrate/references/auth-billing-seams.md +199 -0
  6. package/skills/hogsend-integrate/references/framework-recipes.md +208 -0
  7. package/skills/hogsend-integrate/references/verification.md +86 -0
  8. package/skills/hogsend-migrate/SKILL.md +147 -0
  9. package/skills/hogsend-migrate/references/customerio-mapping.md +93 -0
  10. package/skills/hogsend-migrate/references/cutover-checklist.md +136 -0
  11. package/skills/hogsend-migrate/references/loops-mapping.md +132 -0
  12. package/skills/hogsend-migrate/references/resend-broadcasts-mapping.md +120 -0
  13. package/src/__tests__/dev.test.ts +323 -0
  14. package/src/__tests__/dns-apply.test.ts +297 -0
  15. package/src/__tests__/dns.test.ts +143 -0
  16. package/src/__tests__/domain-command.test.ts +216 -0
  17. package/src/__tests__/proc.test.ts +177 -0
  18. package/src/__tests__/setup-steps.test.ts +363 -0
  19. package/src/commands/dev.ts +444 -0
  20. package/src/commands/domain.ts +437 -0
  21. package/src/commands/events.ts +4 -1
  22. package/src/commands/index.ts +4 -0
  23. package/src/commands/setup.ts +34 -163
  24. package/src/lib/dns-apply.ts +218 -0
  25. package/src/lib/dns.ts +217 -0
  26. package/src/lib/proc.ts +189 -0
  27. package/src/lib/setup-steps.ts +333 -0
  28. package/studio/assets/index-CSXAjTbe.js +265 -0
  29. package/studio/assets/index-DCsT0fnT.css +1 -0
  30. package/studio/index.html +2 -2
  31. package/studio/assets/index-BBOTQnww.js +0 -250
  32. package/studio/assets/index-DnfpcXbb.css +0 -1
@@ -0,0 +1,297 @@
1
+ import type { DnsRecord } from "@hogsend/engine";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { applyRecords, canAutoApply } from "../lib/dns-apply.js";
4
+
5
+ /** Mocked fetch — NEVER hits the real Cloudflare/Vercel APIs. */
6
+ function jsonResponse(body: unknown, status = 200): Response {
7
+ return new Response(JSON.stringify(body), {
8
+ status,
9
+ headers: { "Content-Type": "application/json" },
10
+ });
11
+ }
12
+
13
+ const RECORDS: DnsRecord[] = [
14
+ {
15
+ type: "TXT",
16
+ name: "resend._domainkey.mysite.com",
17
+ value: "p=MIGfMA0GCSq",
18
+ purpose: "dkim",
19
+ status: "pending",
20
+ },
21
+ {
22
+ type: "MX",
23
+ name: "send.mysite.com",
24
+ value: "feedback-smtp.us-east-1.amazonses.com",
25
+ priority: 10,
26
+ purpose: "spf",
27
+ status: "pending",
28
+ },
29
+ ];
30
+
31
+ describe("canAutoApply", () => {
32
+ it("cloudflare requires CLOUDFLARE_API_TOKEN", () => {
33
+ expect(canAutoApply("cloudflare", { CLOUDFLARE_API_TOKEN: "t" })).toBe(
34
+ true,
35
+ );
36
+ expect(canAutoApply("cloudflare", {})).toBe(false);
37
+ });
38
+
39
+ it("vercel requires VERCEL_TOKEN", () => {
40
+ expect(canAutoApply("vercel", { VERCEL_TOKEN: "t" })).toBe(true);
41
+ expect(canAutoApply("vercel", {})).toBe(false);
42
+ });
43
+
44
+ it("every other host is false even with creds set", () => {
45
+ for (const host of [
46
+ "route53",
47
+ "godaddy",
48
+ "namecheap",
49
+ "porkbun",
50
+ "google",
51
+ "unknown",
52
+ ] as const) {
53
+ expect(
54
+ canAutoApply(host, { CLOUDFLARE_API_TOKEN: "t", VERCEL_TOKEN: "t" }),
55
+ ).toBe(false);
56
+ }
57
+ });
58
+ });
59
+
60
+ describe("applyRecords — cloudflare", () => {
61
+ it("resolves the zone then POSTs each record with proxied:false", async () => {
62
+ const fetchImpl = vi
63
+ .fn()
64
+ .mockResolvedValueOnce(
65
+ jsonResponse({ success: true, result: [{ id: "zone1" }] }),
66
+ )
67
+ .mockResolvedValueOnce(jsonResponse({ success: true, result: {} }))
68
+ .mockResolvedValueOnce(jsonResponse({ success: true, result: {} }));
69
+
70
+ const result = await applyRecords({
71
+ host: "cloudflare",
72
+ domain: "mysite.com",
73
+ records: RECORDS,
74
+ env: { CLOUDFLARE_API_TOKEN: "cf_token" },
75
+ fetchImpl,
76
+ });
77
+
78
+ expect(result.applied).toHaveLength(2);
79
+ expect(result.skipped).toHaveLength(0);
80
+ expect(result.errors).toHaveLength(0);
81
+
82
+ const [zoneUrl, zoneInit] = fetchImpl.mock.calls[0] as [
83
+ string,
84
+ RequestInit,
85
+ ];
86
+ expect(zoneUrl).toBe(
87
+ "https://api.cloudflare.com/client/v4/zones?name=mysite.com",
88
+ );
89
+ expect((zoneInit.headers as Record<string, string>).Authorization).toBe(
90
+ "Bearer cf_token",
91
+ );
92
+
93
+ const [recUrl, recInit] = fetchImpl.mock.calls[1] as [string, RequestInit];
94
+ expect(recUrl).toBe(
95
+ "https://api.cloudflare.com/client/v4/zones/zone1/dns_records",
96
+ );
97
+ const payload = JSON.parse(recInit.body as string);
98
+ expect(payload.proxied).toBe(false);
99
+ expect(payload.type).toBe("TXT");
100
+ expect(payload.name).toBe("resend._domainkey.mysite.com");
101
+
102
+ // MX priority threaded through.
103
+ const mxPayload = JSON.parse(
104
+ (fetchImpl.mock.calls[2] as [string, RequestInit])[1].body as string,
105
+ );
106
+ expect(mxPayload.priority).toBe(10);
107
+ });
108
+
109
+ it("counts an identical-record-exists error (81057) as skipped", async () => {
110
+ const fetchImpl = vi
111
+ .fn()
112
+ .mockResolvedValueOnce(
113
+ jsonResponse({ success: true, result: [{ id: "zone1" }] }),
114
+ )
115
+ .mockResolvedValueOnce(
116
+ jsonResponse(
117
+ {
118
+ success: false,
119
+ errors: [{ code: 81057, message: "Record already exists." }],
120
+ },
121
+ 400,
122
+ ),
123
+ )
124
+ .mockResolvedValueOnce(jsonResponse({ success: true, result: {} }));
125
+
126
+ const result = await applyRecords({
127
+ host: "cloudflare",
128
+ domain: "mysite.com",
129
+ records: RECORDS,
130
+ env: { CLOUDFLARE_API_TOKEN: "cf_token" },
131
+ fetchImpl,
132
+ });
133
+
134
+ expect(result.skipped).toHaveLength(1);
135
+ expect(result.applied).toHaveLength(1);
136
+ expect(result.errors).toHaveLength(0);
137
+ });
138
+
139
+ it("collects other API failures into errors (never throws)", async () => {
140
+ const fetchImpl = vi
141
+ .fn()
142
+ .mockResolvedValueOnce(
143
+ jsonResponse({ success: true, result: [{ id: "zone1" }] }),
144
+ )
145
+ .mockResolvedValueOnce(
146
+ jsonResponse(
147
+ { success: false, errors: [{ code: 9999, message: "API boom" }] },
148
+ 400,
149
+ ),
150
+ )
151
+ .mockResolvedValueOnce(jsonResponse({ success: true, result: {} }));
152
+
153
+ const result = await applyRecords({
154
+ host: "cloudflare",
155
+ domain: "mysite.com",
156
+ records: RECORDS,
157
+ env: { CLOUDFLARE_API_TOKEN: "cf_token" },
158
+ fetchImpl,
159
+ });
160
+
161
+ expect(result.errors).toHaveLength(1);
162
+ expect(result.errors[0]).toContain("API boom");
163
+ expect(result.applied).toHaveLength(1);
164
+ });
165
+
166
+ it("errors out cleanly when the zone cannot be resolved", async () => {
167
+ const fetchImpl = vi
168
+ .fn()
169
+ .mockResolvedValueOnce(jsonResponse({ success: true, result: [] }));
170
+
171
+ const result = await applyRecords({
172
+ host: "cloudflare",
173
+ domain: "mysite.com",
174
+ records: RECORDS,
175
+ env: { CLOUDFLARE_API_TOKEN: "cf_token" },
176
+ fetchImpl,
177
+ });
178
+
179
+ expect(result.applied).toHaveLength(0);
180
+ expect(result.skipped).toHaveLength(0);
181
+ expect(result.errors).toHaveLength(1);
182
+ expect(result.errors[0]).toContain("zone");
183
+ });
184
+
185
+ it("resolves the zone by the registrable domain for subdomains", async () => {
186
+ const fetchImpl = vi
187
+ .fn()
188
+ .mockResolvedValueOnce(
189
+ jsonResponse({ success: true, result: [{ id: "zone1" }] }),
190
+ )
191
+ .mockResolvedValue(jsonResponse({ success: true, result: {} }));
192
+
193
+ await applyRecords({
194
+ host: "cloudflare",
195
+ domain: "mail.mysite.com",
196
+ records: RECORDS,
197
+ env: { CLOUDFLARE_API_TOKEN: "cf_token" },
198
+ fetchImpl,
199
+ });
200
+
201
+ const [zoneUrl] = fetchImpl.mock.calls[0] as [string];
202
+ expect(zoneUrl).toContain("name=mysite.com");
203
+ });
204
+ });
205
+
206
+ describe("applyRecords — vercel", () => {
207
+ it("POSTs each record to the domains records API", async () => {
208
+ const fetchImpl = vi.fn().mockResolvedValue(jsonResponse({ uid: "rec_1" }));
209
+
210
+ const result = await applyRecords({
211
+ host: "vercel",
212
+ domain: "mysite.com",
213
+ records: RECORDS,
214
+ env: { VERCEL_TOKEN: "v_token" },
215
+ fetchImpl,
216
+ });
217
+
218
+ expect(result.applied).toHaveLength(2);
219
+ const [url, init] = fetchImpl.mock.calls[0] as [string, RequestInit];
220
+ expect(url).toBe("https://api.vercel.com/v2/domains/mysite.com/records");
221
+ expect((init.headers as Record<string, string>).Authorization).toBe(
222
+ "Bearer v_token",
223
+ );
224
+ const payload = JSON.parse(init.body as string);
225
+ expect(payload.type).toBe("TXT");
226
+ });
227
+
228
+ it("appends ?teamId= when VERCEL_TEAM_ID is set", async () => {
229
+ const fetchImpl = vi.fn().mockResolvedValue(jsonResponse({ uid: "rec_1" }));
230
+
231
+ await applyRecords({
232
+ host: "vercel",
233
+ domain: "mysite.com",
234
+ records: RECORDS.slice(0, 1),
235
+ env: { VERCEL_TOKEN: "v_token", VERCEL_TEAM_ID: "team_1" },
236
+ fetchImpl,
237
+ });
238
+
239
+ const [url] = fetchImpl.mock.calls[0] as [string];
240
+ expect(url).toContain("?teamId=team_1");
241
+ });
242
+
243
+ it("counts a duplicate-record error as skipped", async () => {
244
+ const fetchImpl = vi
245
+ .fn()
246
+ .mockResolvedValueOnce(
247
+ jsonResponse(
248
+ {
249
+ error: {
250
+ code: "duplicate_record",
251
+ message: "A record already exists",
252
+ },
253
+ },
254
+ 400,
255
+ ),
256
+ )
257
+ .mockResolvedValueOnce(jsonResponse({ uid: "rec_2" }));
258
+
259
+ const result = await applyRecords({
260
+ host: "vercel",
261
+ domain: "mysite.com",
262
+ records: RECORDS,
263
+ env: { VERCEL_TOKEN: "v_token" },
264
+ fetchImpl,
265
+ });
266
+
267
+ expect(result.skipped).toHaveLength(1);
268
+ expect(result.applied).toHaveLength(1);
269
+ expect(result.errors).toHaveLength(0);
270
+ });
271
+ });
272
+
273
+ describe("applyRecords — unsupported host / missing creds", () => {
274
+ it("returns everything skipped with an explanatory error", async () => {
275
+ const result = await applyRecords({
276
+ host: "godaddy",
277
+ domain: "mysite.com",
278
+ records: RECORDS,
279
+ env: {},
280
+ });
281
+ expect(result.applied).toHaveLength(0);
282
+ expect(result.skipped).toHaveLength(2);
283
+ expect(result.errors).toHaveLength(1);
284
+ });
285
+
286
+ it("never throws on a transport failure", async () => {
287
+ const fetchImpl = vi.fn().mockRejectedValue(new Error("ECONNREFUSED"));
288
+ const result = await applyRecords({
289
+ host: "cloudflare",
290
+ domain: "mysite.com",
291
+ records: RECORDS,
292
+ env: { CLOUDFLARE_API_TOKEN: "cf_token" },
293
+ fetchImpl,
294
+ });
295
+ expect(result.errors.length).toBeGreaterThan(0);
296
+ });
297
+ });
@@ -0,0 +1,143 @@
1
+ import type { DnsRecord } from "@hogsend/engine";
2
+ import { describe, expect, it } from "vitest";
3
+ import { DNS_HOSTS, detectDnsHost, formatRecordsFor } from "../lib/dns.js";
4
+
5
+ /** Fixture resolver — NEVER touches real DNS. */
6
+ function resolverFor(map: Record<string, readonly string[]>) {
7
+ return async (hostname: string): Promise<string[]> => {
8
+ const ns = map[hostname];
9
+ if (!ns) {
10
+ const err = new Error(`queryNs ENOTFOUND ${hostname}`) as Error & {
11
+ code: string;
12
+ };
13
+ err.code = "ENOTFOUND";
14
+ throw err;
15
+ }
16
+ return [...ns];
17
+ };
18
+ }
19
+
20
+ const RECORDS: DnsRecord[] = [
21
+ {
22
+ type: "TXT",
23
+ name: "resend._domainkey.mysite.com",
24
+ value: "p=MIGfMA0GCSq",
25
+ purpose: "dkim",
26
+ status: "pending",
27
+ },
28
+ {
29
+ type: "MX",
30
+ name: "send.mysite.com",
31
+ value: "feedback-smtp.us-east-1.amazonses.com",
32
+ priority: 10,
33
+ purpose: "spf",
34
+ status: "pending",
35
+ },
36
+ {
37
+ type: "CNAME",
38
+ name: "pm-bounces.mysite.com",
39
+ value: "pm.mtasv.net",
40
+ purpose: "return_path",
41
+ status: "verified",
42
+ },
43
+ ];
44
+
45
+ describe("detectDnsHost", () => {
46
+ it.each([
47
+ ["cloudflare", ["dana.ns.cloudflare.com", "kirk.ns.cloudflare.com"]],
48
+ ["vercel", ["ns1.vercel-dns.com", "ns2.vercel-dns.com"]],
49
+ ["route53", ["ns-123.awsdns-15.com", "ns-456.awsdns-22.net"]],
50
+ ["godaddy", ["ns01.domaincontrol.com", "ns02.domaincontrol.com"]],
51
+ ["namecheap", ["dns1.registrar-servers.com", "dns2.registrar-servers.com"]],
52
+ ["porkbun", ["maceio.ns.porkbun.com", "salvador.ns.porkbun.com"]],
53
+ ["google", ["ns-cloud-a1.googledomains.com"]],
54
+ ] as const)("detects %s from its NS suffixes", async (id, ns) => {
55
+ const host = await detectDnsHost("mysite.com", {
56
+ resolveNs: resolverFor({ "mysite.com": ns }),
57
+ });
58
+ expect(host.id).toBe(id);
59
+ });
60
+
61
+ it("walks up labels until NS records resolve", async () => {
62
+ const host = await detectDnsHost("a.b.mysite.com", {
63
+ resolveNs: resolverFor({ "mysite.com": ["dana.ns.cloudflare.com"] }),
64
+ });
65
+ expect(host.id).toBe("cloudflare");
66
+ });
67
+
68
+ it("returns unknown for an unrecognized nameserver", async () => {
69
+ const host = await detectDnsHost("mysite.com", {
70
+ resolveNs: resolverFor({ "mysite.com": ["ns1.example-dns.io"] }),
71
+ });
72
+ expect(host.id).toBe("unknown");
73
+ });
74
+
75
+ it("returns unknown when every lookup errors (never throws)", async () => {
76
+ const host = await detectDnsHost("mysite.com", {
77
+ resolveNs: resolverFor({}),
78
+ });
79
+ expect(host.id).toBe("unknown");
80
+ });
81
+
82
+ it("exposes a panel deep link per host", () => {
83
+ const cloudflare = DNS_HOSTS.cloudflare;
84
+ expect(cloudflare.panelUrl("mysite.com")).toBe(
85
+ "https://dash.cloudflare.com/?to=/:account/mysite.com/dns/records",
86
+ );
87
+ expect(DNS_HOSTS.vercel.panelUrl("mysite.com")).toBe(
88
+ "https://vercel.com/dashboard/domains/mysite.com",
89
+ );
90
+ expect(DNS_HOSTS.namecheap.panelUrl("mysite.com")).toContain(
91
+ "domaincontrolpanel/mysite.com",
92
+ );
93
+ expect(DNS_HOSTS.unknown.panelUrl("mysite.com")).toBe("https://mysite.com");
94
+ });
95
+ });
96
+
97
+ describe("formatRecordsFor", () => {
98
+ it("renders an aligned table with type/name/value/priority/status", () => {
99
+ const out = formatRecordsFor(DNS_HOSTS.cloudflare, RECORDS, {
100
+ domain: "mysite.com",
101
+ });
102
+ expect(out).toContain("TXT");
103
+ expect(out).toContain("resend._domainkey.mysite.com");
104
+ expect(out).toContain("p=MIGfMA0GCSq");
105
+ expect(out).toContain("10");
106
+ expect(out).toContain("pending");
107
+ expect(out).toContain("verified");
108
+ });
109
+
110
+ it("adds the Cloudflare grey-cloud guidance", () => {
111
+ const out = formatRecordsFor(DNS_HOSTS.cloudflare, RECORDS, {
112
+ domain: "mysite.com",
113
+ });
114
+ expect(out).toContain("DNS only");
115
+ });
116
+
117
+ it("strips the domain suffix for relative-host panels (namecheap/godaddy)", () => {
118
+ for (const id of ["namecheap", "godaddy"] as const) {
119
+ const out = formatRecordsFor(DNS_HOSTS[id], RECORDS, {
120
+ domain: "mysite.com",
121
+ });
122
+ // Hosts shown RELATIVE to mysite.com.
123
+ expect(out).toContain("resend._domainkey");
124
+ expect(out).not.toContain("resend._domainkey.mysite.com");
125
+ expect(out).toContain("RELATIVE");
126
+ }
127
+ });
128
+
129
+ it("prints records verbatim with a generic note for unknown hosts", () => {
130
+ const out = formatRecordsFor(DNS_HOSTS.unknown, RECORDS, {
131
+ domain: "mysite.com",
132
+ });
133
+ expect(out).toContain("resend._domainkey.mysite.com");
134
+ expect(out.toLowerCase()).toContain("dns");
135
+ });
136
+
137
+ it("handles an empty record list", () => {
138
+ const out = formatRecordsFor(DNS_HOSTS.cloudflare, [], {
139
+ domain: "mysite.com",
140
+ });
141
+ expect(typeof out).toBe("string");
142
+ });
143
+ });
@@ -0,0 +1,216 @@
1
+ import type { EngineDomainStatus } from "@hogsend/engine";
2
+ import { describe, expect, it } from "vitest";
3
+ import { domainCommand } from "../commands/domain.js";
4
+ import type { CommandContext } from "../commands/types.js";
5
+ import type { ResolvedConfig } from "../lib/config.js";
6
+ import type { AdminClient, HttpError, Query } from "../lib/http.js";
7
+ import type { Output } from "../lib/output.js";
8
+
9
+ /** Sentinel thrown by the stubbed `out.fail` instead of process.exit(1). */
10
+ class FailSignal extends Error {
11
+ constructor(readonly failMessage: string) {
12
+ super(failMessage);
13
+ this.name = "FailSignal";
14
+ }
15
+ }
16
+
17
+ function makeHttpError(status: number, body: unknown): HttpError {
18
+ const err = new Error(
19
+ body &&
20
+ typeof body === "object" &&
21
+ "error" in body &&
22
+ typeof (body as { error: unknown }).error === "string"
23
+ ? `${status}: ${(body as { error: string }).error}`
24
+ : `request failed with status ${status}`,
25
+ ) as HttpError;
26
+ err.name = "HttpError";
27
+ err.status = status;
28
+ err.body = body;
29
+ return err;
30
+ }
31
+
32
+ const STATUS_FIXTURE: EngineDomainStatus = {
33
+ domain: "mysite.com",
34
+ providerId: "resend",
35
+ supported: true,
36
+ status: {
37
+ domain: "mysite.com",
38
+ state: "pending",
39
+ records: [
40
+ {
41
+ type: "TXT",
42
+ name: "resend._domainkey.mysite.com",
43
+ value: "p=MIGfMA0GCSq",
44
+ purpose: "dkim",
45
+ status: "pending",
46
+ },
47
+ ],
48
+ providerId: "resend",
49
+ checkedAt: "2026-06-09T00:00:00.000Z",
50
+ },
51
+ testMode: {
52
+ active: false,
53
+ reason: null,
54
+ redirectTo: null,
55
+ fromOverride: null,
56
+ },
57
+ };
58
+
59
+ interface CapturedOutput {
60
+ logs: string[];
61
+ jsonDocs: unknown[];
62
+ }
63
+
64
+ function makeCtx(opts: {
65
+ argv: string[];
66
+ json?: boolean;
67
+ get?: (path: string, query?: Query) => Promise<unknown>;
68
+ post?: (path: string, body: unknown) => Promise<unknown>;
69
+ }): { ctx: CommandContext; captured: CapturedOutput } {
70
+ const captured: CapturedOutput = { logs: [], jsonDocs: [] };
71
+
72
+ const out: Output = {
73
+ interactive: false,
74
+ isJson: opts.json ?? false,
75
+ intro: () => {},
76
+ step: async <T>(_label: string, fn: () => Promise<T>) => fn(),
77
+ note: (body: string) => {
78
+ captured.logs.push(body);
79
+ },
80
+ table: () => {},
81
+ kv: () => {},
82
+ log: (msg: string) => {
83
+ captured.logs.push(msg);
84
+ },
85
+ json: (payload: unknown) => {
86
+ captured.jsonDocs.push(payload);
87
+ },
88
+ outro: () => {},
89
+ fail: (message: string): never => {
90
+ throw new FailSignal(message);
91
+ },
92
+ };
93
+
94
+ const cfg = {
95
+ baseUrl: "http://localhost:3002",
96
+ adminKey: "hsk_test",
97
+ dataKey: undefined,
98
+ sources: { baseUrl: "default", adminKey: "flag", dataKey: "default" },
99
+ } as unknown as ResolvedConfig;
100
+
101
+ const http = {
102
+ cfg,
103
+ get: (path: string, query?: Query) =>
104
+ (opts.get ?? (() => Promise.reject(new Error("unexpected GET"))))(
105
+ path,
106
+ query,
107
+ ),
108
+ post: (path: string, body: unknown) =>
109
+ (opts.post ?? (() => Promise.reject(new Error("unexpected POST"))))(
110
+ path,
111
+ body,
112
+ ),
113
+ patch: () => Promise.reject(new Error("unexpected PATCH")),
114
+ del: () => Promise.reject(new Error("unexpected DELETE")),
115
+ } as AdminClient;
116
+
117
+ const ctx: CommandContext = {
118
+ argv: opts.argv,
119
+ cfg,
120
+ http,
121
+ dataHttp: {} as CommandContext["dataHttp"],
122
+ out,
123
+ json: opts.json ?? false,
124
+ };
125
+
126
+ return { ctx, captured };
127
+ }
128
+
129
+ describe("hogsend domain --help", () => {
130
+ it("prints usage and exits cleanly (exit 0)", async () => {
131
+ const { ctx, captured } = makeCtx({ argv: ["--help"] });
132
+ await domainCommand.run(ctx);
133
+ expect(captured.logs.join("\n")).toContain("hogsend domain");
134
+ expect(captured.logs.join("\n")).toContain("add <domain>");
135
+ });
136
+
137
+ it("prints usage when no subcommand is given", async () => {
138
+ const { ctx, captured } = makeCtx({ argv: [] });
139
+ await domainCommand.run(ctx);
140
+ expect(captured.logs.join("\n")).toContain("hogsend domain");
141
+ });
142
+
143
+ it("fails on an unknown subcommand", async () => {
144
+ const { ctx } = makeCtx({ argv: ["frobnicate"] });
145
+ await expect(domainCommand.run(ctx)).rejects.toThrow(/unknown subcommand/i);
146
+ });
147
+ });
148
+
149
+ describe("hogsend domain status", () => {
150
+ it("--json emits the EngineDomainStatus as a single parseable document", async () => {
151
+ const { ctx, captured } = makeCtx({
152
+ argv: ["status"],
153
+ json: true,
154
+ get: async (path, query) => {
155
+ expect(path).toBe("/v1/admin/domain");
156
+ expect(query?.refresh).toBeUndefined();
157
+ return STATUS_FIXTURE;
158
+ },
159
+ });
160
+ await domainCommand.run(ctx);
161
+ expect(captured.jsonDocs).toHaveLength(1);
162
+ const doc = JSON.parse(JSON.stringify(captured.jsonDocs[0]));
163
+ expect(doc).toEqual(STATUS_FIXTURE);
164
+ expect(doc.testMode).toEqual({
165
+ active: false,
166
+ reason: null,
167
+ redirectTo: null,
168
+ fromOverride: null,
169
+ });
170
+ });
171
+
172
+ it("--refresh passes ?refresh=true", async () => {
173
+ let seenQuery: Query | undefined;
174
+ const { ctx } = makeCtx({
175
+ argv: ["status", "--refresh"],
176
+ json: true,
177
+ get: async (_path, query) => {
178
+ seenQuery = query;
179
+ return STATUS_FIXTURE;
180
+ },
181
+ });
182
+ await domainCommand.run(ctx);
183
+ expect(seenQuery?.refresh).toBe("true");
184
+ });
185
+ });
186
+
187
+ describe("hogsend domain add", () => {
188
+ it("fails with the unsupported message on a 501 provider_unsupported", async () => {
189
+ const { ctx } = makeCtx({
190
+ argv: ["add", "mysite.com"],
191
+ post: async () => {
192
+ throw makeHttpError(501, { error: "provider_unsupported" });
193
+ },
194
+ // The command resolves the provider id for the message via GET.
195
+ get: async () => ({
196
+ ...STATUS_FIXTURE,
197
+ providerId: "smtp",
198
+ supported: false,
199
+ status: null,
200
+ }),
201
+ });
202
+ await expect(domainCommand.run(ctx)).rejects.toThrow(
203
+ /provider smtp does not support domain management/,
204
+ );
205
+ });
206
+
207
+ it("fails when the domain argument is missing", async () => {
208
+ const { ctx } = makeCtx({ argv: ["add"] });
209
+ await expect(domainCommand.run(ctx)).rejects.toThrow(/missing <domain>/i);
210
+ });
211
+
212
+ it("fails on an invalid domain before any HTTP call", async () => {
213
+ const { ctx } = makeCtx({ argv: ["add", "not_a_domain"] });
214
+ await expect(domainCommand.run(ctx)).rejects.toThrow(/invalid domain/i);
215
+ });
216
+ });