@hogsend/cli 0.11.0 → 0.12.1
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 +1985 -807
- package/dist/bin.js.map +1 -1
- package/package.json +4 -4
- package/skills/hogsend-integrate/SKILL.md +198 -0
- package/skills/hogsend-integrate/references/auth-billing-seams.md +199 -0
- package/skills/hogsend-integrate/references/framework-recipes.md +208 -0
- package/skills/hogsend-integrate/references/verification.md +86 -0
- package/skills/hogsend-migrate/SKILL.md +147 -0
- package/skills/hogsend-migrate/references/customerio-mapping.md +93 -0
- package/skills/hogsend-migrate/references/cutover-checklist.md +136 -0
- package/skills/hogsend-migrate/references/loops-mapping.md +132 -0
- package/skills/hogsend-migrate/references/resend-broadcasts-mapping.md +120 -0
- package/src/__tests__/dev.test.ts +323 -0
- package/src/__tests__/dns-apply.test.ts +297 -0
- package/src/__tests__/dns.test.ts +143 -0
- package/src/__tests__/domain-command.test.ts +216 -0
- package/src/__tests__/proc.test.ts +177 -0
- package/src/__tests__/setup-steps.test.ts +363 -0
- package/src/commands/dev.ts +444 -0
- package/src/commands/domain.ts +437 -0
- package/src/commands/events.ts +4 -1
- package/src/commands/index.ts +4 -0
- package/src/commands/setup.ts +34 -163
- package/src/lib/dns-apply.ts +218 -0
- package/src/lib/dns.ts +217 -0
- package/src/lib/proc.ts +189 -0
- package/src/lib/setup-steps.ts +333 -0
- package/studio/assets/index-CSXAjTbe.js +265 -0
- package/studio/assets/index-DCsT0fnT.css +1 -0
- package/studio/index.html +2 -2
- package/studio/assets/index-BBOTQnww.js +0 -250
- package/studio/assets/index-DnfpcXbb.css +0 -1
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# Resend Broadcasts/Audiences → Hogsend mapping
|
|
2
|
+
|
|
3
|
+
Audit greps, the concept table, and rewrites for a codebase using Resend's
|
|
4
|
+
Broadcasts + Audiences + Contacts APIs. Resend's API may differ by SDK version —
|
|
5
|
+
treat the left-hand column as "look for", and verify against the project's
|
|
6
|
+
actual calls.
|
|
7
|
+
|
|
8
|
+
**The special case first: Resend can stay.** Hogsend's default `EmailProvider`
|
|
9
|
+
IS Resend (`@hogsend/plugin-resend`) — the same `RESEND_API_KEY` keeps
|
|
10
|
+
delivering the mail. This migration replaces the ORCHESTRATION layer (who
|
|
11
|
+
decides what to send to whom, when) — broadcasts, audiences, contact state —
|
|
12
|
+
not necessarily the wire. That makes it the lowest-risk migration of the three:
|
|
13
|
+
deliverability, domains, and DKIM are already proven.
|
|
14
|
+
|
|
15
|
+
## Audit greps (code half)
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
grep -rn "resend" package.json
|
|
19
|
+
# The orchestration surface being migrated:
|
|
20
|
+
grep -rn "resend.broadcasts\.\|resend.audiences\.\|resend.contacts\." src/
|
|
21
|
+
# Plain transactional — DISTINCT, may stay on resend.emails.send or move:
|
|
22
|
+
grep -rn "resend.emails.send" src/
|
|
23
|
+
grep -rn "RESEND_API_KEY\|RESEND_AUDIENCE" . --include="*.env*" --include="*.ts"
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
GUI half (from the Resend dashboard): audiences + contact counts, broadcast
|
|
27
|
+
history + their content, unsubscribed contacts per audience, and the verified
|
|
28
|
+
sending domains (these carry over untouched when Resend stays the provider).
|
|
29
|
+
|
|
30
|
+
## Concept mapping
|
|
31
|
+
|
|
32
|
+
| Resend concept | Hogsend equivalent | Notes |
|
|
33
|
+
|---|---|---|
|
|
34
|
+
| Audience | list — `defineList({ id, name, defaultOptIn })` | Resend audiences are explicit-membership mailing lists → almost always opt-in (`defaultOptIn: false`) |
|
|
35
|
+
| Contact (in an audience) | contact + list membership — `hs.contacts.upsert({ email, properties?, lists: { "<list-id>": true } })` | `firstName`/`lastName` → `properties`; `unsubscribed: true` contacts → suppression import (see `cutover-checklist.md`) |
|
|
36
|
+
| Broadcast | campaign — `hs.campaigns.send({ list, template, props })` | Or `hogsend campaigns send --list <id> --template <key>`. Content becomes a four-file react-email template |
|
|
37
|
+
| `resend.emails.send` (transactional) | EITHER keep as-is (it still works) OR move to `hs.emails.send` | Moving buys first-party open/click tracking, preference/suppression checks, send history, and journey attribution — recommended, not required |
|
|
38
|
+
| Scheduled broadcast | campaign triggered when you want it | A campaign sends on enqueue; schedule via your own cron/workflow if needed |
|
|
39
|
+
| (No workflow product) | journeys — `defineJourney()` | Net-new capability, not a port. Teams on Resend Broadcasts usually hand-rolled drip logic in app code — grep for `setTimeout`/cron-driven email sends worth replacing with journeys |
|
|
40
|
+
|
|
41
|
+
## Before / after
|
|
42
|
+
|
|
43
|
+
Audience + contact management (host product code):
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
// BEFORE (Resend — shapes vary by SDK version)
|
|
47
|
+
await resend.contacts.create({
|
|
48
|
+
email: "ada@example.com",
|
|
49
|
+
firstName: "Ada",
|
|
50
|
+
audienceId: NEWSLETTER_AUDIENCE_ID,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// AFTER (@hogsend/client) — list defined once in the Hogsend app via
|
|
54
|
+
// defineList({ id: "newsletter", name: "Newsletter", defaultOptIn: false })
|
|
55
|
+
import { hogsend } from "../lib/hogsend.js";
|
|
56
|
+
|
|
57
|
+
await hogsend.contacts.upsert({
|
|
58
|
+
email: "ada@example.com",
|
|
59
|
+
userId: user.id,
|
|
60
|
+
properties: { firstName: "Ada" },
|
|
61
|
+
lists: { newsletter: true },
|
|
62
|
+
});
|
|
63
|
+
// (or, membership alone: hogsend.lists.subscribe({ list: "newsletter", email: "ada@example.com" }))
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Broadcast → campaign:
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
// BEFORE: a broadcast created in the Resend dashboard / broadcasts API
|
|
70
|
+
|
|
71
|
+
// AFTER — template authored in the Hogsend app first
|
|
72
|
+
// (four-file contract → hogsend-authoring-emails skill)
|
|
73
|
+
const { campaignId } = await hogsend.campaigns.send({
|
|
74
|
+
list: "newsletter",
|
|
75
|
+
template: "june-update",
|
|
76
|
+
props: {},
|
|
77
|
+
name: "June update",
|
|
78
|
+
});
|
|
79
|
+
// Poll progress: await hogsend.campaigns.get(campaignId)
|
|
80
|
+
// CLI: hogsend campaigns send --list newsletter --template june-update
|
|
81
|
+
// hogsend campaigns status <campaignId> --json
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Transactional, if moving (optional but recommended):
|
|
85
|
+
|
|
86
|
+
```ts
|
|
87
|
+
// BEFORE (Resend direct)
|
|
88
|
+
await resend.emails.send({
|
|
89
|
+
from: "Acme <hello@acme.com>",
|
|
90
|
+
to: "ada@example.com",
|
|
91
|
+
subject: "Reset your password",
|
|
92
|
+
html: renderedHtml,
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// AFTER — Hogsend renders the react-email template, applies tracking +
|
|
96
|
+
// preference checks, records the send, THEN delivers via the same Resend key
|
|
97
|
+
await hogsend.emails.send({
|
|
98
|
+
to: "ada@example.com",
|
|
99
|
+
template: "password-reset",
|
|
100
|
+
props: { resetUrl },
|
|
101
|
+
});
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Resend-specific gotchas
|
|
105
|
+
|
|
106
|
+
- **Don't double-manage contacts.** Once Hogsend owns the audience (as a
|
|
107
|
+
list), stop writing to `resend.audiences`/`resend.contacts` — Hogsend's
|
|
108
|
+
provider integration uses Resend purely as a send wire, not as a contact
|
|
109
|
+
store.
|
|
110
|
+
- **Unsubscribes move to Hogsend.** Hogsend injects its own unsubscribe/
|
|
111
|
+
preference links and stores state in its `email_preferences`. Import Resend's
|
|
112
|
+
per-audience unsubscribed contacts BEFORE the first campaign
|
|
113
|
+
(`cutover-checklist.md`), and stop relying on Resend-side unsubscribe state
|
|
114
|
+
afterward.
|
|
115
|
+
- **Domains/DKIM carry over** when Resend stays the provider — verify
|
|
116
|
+
`RESEND_API_KEY` (and the from-address envs) are set on the Hogsend
|
|
117
|
+
deployment, then nothing else changes on the deliverability side.
|
|
118
|
+
- **Broadcast HTML is re-authored**, not pasted: rebuild as a react-email
|
|
119
|
+
component using the Hogsend app's `src/emails/_components/` chrome so
|
|
120
|
+
tracking + unsubscribe slots work.
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import {
|
|
6
|
+
devCommand,
|
|
7
|
+
fetchDomainLine,
|
|
8
|
+
renderDomainLine,
|
|
9
|
+
} from "../commands/dev.js";
|
|
10
|
+
import type { CommandContext } from "../commands/types.js";
|
|
11
|
+
import type { ResolvedConfig } from "../lib/config.js";
|
|
12
|
+
import type { AdminClient, DataPlaneClient, Query } from "../lib/http.js";
|
|
13
|
+
import type { Output } from "../lib/output.js";
|
|
14
|
+
|
|
15
|
+
vi.mock("../lib/proc.js", () => ({
|
|
16
|
+
spawnManaged: vi.fn(),
|
|
17
|
+
shutdownAll: vi.fn(async () => {}),
|
|
18
|
+
waitForHttp: vi.fn(async () => {}),
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
import { spawnManaged } from "../lib/proc.js";
|
|
22
|
+
|
|
23
|
+
/** Sentinel thrown by the stubbed `out.fail` instead of process.exit(1). */
|
|
24
|
+
class FailSignal extends Error {
|
|
25
|
+
constructor(readonly failMessage: string) {
|
|
26
|
+
super(failMessage);
|
|
27
|
+
this.name = "FailSignal";
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface Captured {
|
|
32
|
+
logs: string[];
|
|
33
|
+
jsonDocs: unknown[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function makeCtx(opts: {
|
|
37
|
+
argv: string[];
|
|
38
|
+
json?: boolean;
|
|
39
|
+
adminKey?: string;
|
|
40
|
+
get?: (path: string, query?: Query) => Promise<unknown>;
|
|
41
|
+
post?: (path: string, body: unknown) => Promise<unknown>;
|
|
42
|
+
}): { ctx: CommandContext; captured: Captured } {
|
|
43
|
+
const captured: Captured = { logs: [], jsonDocs: [] };
|
|
44
|
+
|
|
45
|
+
const out: Output = {
|
|
46
|
+
interactive: false,
|
|
47
|
+
isJson: opts.json ?? false,
|
|
48
|
+
intro: () => {},
|
|
49
|
+
step: async <T>(_label: string, fn: () => Promise<T>) => fn(),
|
|
50
|
+
note: (body: string) => {
|
|
51
|
+
captured.logs.push(body);
|
|
52
|
+
},
|
|
53
|
+
table: () => {},
|
|
54
|
+
kv: () => {},
|
|
55
|
+
log: (msg: string) => {
|
|
56
|
+
captured.logs.push(msg);
|
|
57
|
+
},
|
|
58
|
+
json: (payload: unknown) => {
|
|
59
|
+
captured.jsonDocs.push(payload);
|
|
60
|
+
},
|
|
61
|
+
outro: () => {},
|
|
62
|
+
fail: (message: string): never => {
|
|
63
|
+
throw new FailSignal(message);
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const cfg = {
|
|
68
|
+
baseUrl: "http://localhost:3002",
|
|
69
|
+
adminKey: opts.adminKey,
|
|
70
|
+
dataKey: "hsk_data",
|
|
71
|
+
} as ResolvedConfig;
|
|
72
|
+
|
|
73
|
+
const http = {
|
|
74
|
+
cfg,
|
|
75
|
+
get: (path: string, query?: Query) =>
|
|
76
|
+
(opts.get ?? (() => Promise.reject(new Error("unexpected GET"))))(
|
|
77
|
+
path,
|
|
78
|
+
query,
|
|
79
|
+
),
|
|
80
|
+
post: () => Promise.reject(new Error("unexpected POST")),
|
|
81
|
+
patch: () => Promise.reject(new Error("unexpected PATCH")),
|
|
82
|
+
del: () => Promise.reject(new Error("unexpected DELETE")),
|
|
83
|
+
} as AdminClient;
|
|
84
|
+
|
|
85
|
+
const dataHttp = {
|
|
86
|
+
cfg,
|
|
87
|
+
get: () => Promise.reject(new Error("unexpected data GET")),
|
|
88
|
+
post: (path: string, body: unknown) =>
|
|
89
|
+
(opts.post ?? (() => Promise.reject(new Error("unexpected data POST"))))(
|
|
90
|
+
path,
|
|
91
|
+
body,
|
|
92
|
+
),
|
|
93
|
+
put: () => Promise.reject(new Error("unexpected PUT")),
|
|
94
|
+
del: () => Promise.reject(new Error("unexpected DELETE")),
|
|
95
|
+
} as DataPlaneClient;
|
|
96
|
+
|
|
97
|
+
const ctx: CommandContext = {
|
|
98
|
+
argv: opts.argv,
|
|
99
|
+
cfg,
|
|
100
|
+
http,
|
|
101
|
+
dataHttp,
|
|
102
|
+
out,
|
|
103
|
+
json: opts.json ?? false,
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
return { ctx, captured };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
let cwd: string;
|
|
110
|
+
|
|
111
|
+
beforeEach(() => {
|
|
112
|
+
cwd = mkdtempSync(join(tmpdir(), "hogsend-dev-"));
|
|
113
|
+
vi.mocked(spawnManaged).mockClear();
|
|
114
|
+
vi.unstubAllGlobals();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
afterEach(() => {
|
|
118
|
+
rmSync(cwd, { recursive: true, force: true });
|
|
119
|
+
vi.unstubAllGlobals();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe("hogsend dev --help", () => {
|
|
123
|
+
it("prints usage and exits cleanly", async () => {
|
|
124
|
+
const { ctx, captured } = makeCtx({ argv: ["--help"] });
|
|
125
|
+
await devCommand.run(ctx);
|
|
126
|
+
const all = captured.logs.join("\n");
|
|
127
|
+
expect(all).toContain("hogsend dev");
|
|
128
|
+
expect(all).toContain("--fire");
|
|
129
|
+
expect(all).toContain("--no-worker");
|
|
130
|
+
expect(all).toContain("--no-infra");
|
|
131
|
+
expect(vi.mocked(spawnManaged)).not.toHaveBeenCalled();
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe("hogsend dev app detection", () => {
|
|
136
|
+
it("fails with 'not a Hogsend app' in an empty directory", async () => {
|
|
137
|
+
const { ctx } = makeCtx({ argv: ["--cwd", cwd] });
|
|
138
|
+
await expect(devCommand.run(ctx)).rejects.toThrow(/not a Hogsend app/i);
|
|
139
|
+
expect(vi.mocked(spawnManaged)).not.toHaveBeenCalled();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("names the missing worker:dev script", async () => {
|
|
143
|
+
writeFileSync(
|
|
144
|
+
join(cwd, "package.json"),
|
|
145
|
+
JSON.stringify({
|
|
146
|
+
name: "app",
|
|
147
|
+
scripts: { dev: "tsx watch src/index.ts" },
|
|
148
|
+
dependencies: { "@hogsend/engine": "^0.11.0" },
|
|
149
|
+
}),
|
|
150
|
+
);
|
|
151
|
+
const { ctx } = makeCtx({ argv: ["--cwd", cwd] });
|
|
152
|
+
await expect(devCommand.run(ctx)).rejects.toThrow(/worker:dev/);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("names the missing @hogsend/engine dependency", async () => {
|
|
156
|
+
writeFileSync(
|
|
157
|
+
join(cwd, "package.json"),
|
|
158
|
+
JSON.stringify({
|
|
159
|
+
name: "app",
|
|
160
|
+
scripts: { dev: "x", "worker:dev": "y" },
|
|
161
|
+
dependencies: {},
|
|
162
|
+
}),
|
|
163
|
+
);
|
|
164
|
+
const { ctx } = makeCtx({ argv: ["--cwd", cwd] });
|
|
165
|
+
await expect(devCommand.run(ctx)).rejects.toThrow(/@hogsend\/engine/);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
describe("hogsend dev --fire", () => {
|
|
170
|
+
it("delegates to the events send path without booting anything", async () => {
|
|
171
|
+
vi.stubGlobal(
|
|
172
|
+
"fetch",
|
|
173
|
+
vi.fn(async () => ({ ok: true })),
|
|
174
|
+
);
|
|
175
|
+
let seenPath: string | undefined;
|
|
176
|
+
let seenBody: unknown;
|
|
177
|
+
const { ctx } = makeCtx({
|
|
178
|
+
argv: ["--fire", "signup", "--email", "a@b.com", "--prop", "plan=pro"],
|
|
179
|
+
post: async (path, body) => {
|
|
180
|
+
seenPath = path;
|
|
181
|
+
seenBody = body;
|
|
182
|
+
return { stored: true, exits: [] };
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
await devCommand.run(ctx);
|
|
186
|
+
expect(seenPath).toBe("/v1/events");
|
|
187
|
+
expect(seenBody).toEqual({
|
|
188
|
+
name: "signup",
|
|
189
|
+
email: "a@b.com",
|
|
190
|
+
eventProperties: { plan: "pro" },
|
|
191
|
+
});
|
|
192
|
+
expect(vi.mocked(spawnManaged)).not.toHaveBeenCalled();
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("supports --fire=<event> syntax", async () => {
|
|
196
|
+
vi.stubGlobal(
|
|
197
|
+
"fetch",
|
|
198
|
+
vi.fn(async () => ({ ok: true })),
|
|
199
|
+
);
|
|
200
|
+
let seenBody: unknown;
|
|
201
|
+
const { ctx } = makeCtx({
|
|
202
|
+
argv: ["--fire=signup", "--user-id", "u_1"],
|
|
203
|
+
post: async (_path, body) => {
|
|
204
|
+
seenBody = body;
|
|
205
|
+
return { stored: true, exits: [] };
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
await devCommand.run(ctx);
|
|
209
|
+
expect(seenBody).toEqual({ name: "signup", userId: "u_1" });
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("fails when --fire has no event name", async () => {
|
|
213
|
+
const { ctx } = makeCtx({ argv: ["--fire"] });
|
|
214
|
+
await expect(devCommand.run(ctx)).rejects.toThrow(
|
|
215
|
+
/--fire requires an event name/,
|
|
216
|
+
);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("fails with a friendly hint when the instance is down", async () => {
|
|
220
|
+
vi.stubGlobal(
|
|
221
|
+
"fetch",
|
|
222
|
+
vi.fn(async () => {
|
|
223
|
+
throw new Error("ECONNREFUSED");
|
|
224
|
+
}),
|
|
225
|
+
);
|
|
226
|
+
const { ctx } = makeCtx({
|
|
227
|
+
argv: ["--fire", "signup", "--user-id", "u_1"],
|
|
228
|
+
});
|
|
229
|
+
await expect(devCommand.run(ctx)).rejects.toThrow(
|
|
230
|
+
/is hogsend dev running/i,
|
|
231
|
+
);
|
|
232
|
+
expect(vi.mocked(spawnManaged)).not.toHaveBeenCalled();
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
describe("renderDomainLine", () => {
|
|
237
|
+
it("renders a yellow test-mode line with the redirect target", () => {
|
|
238
|
+
const line = renderDomainLine({
|
|
239
|
+
domain: "mysite.com",
|
|
240
|
+
status: { state: "pending" },
|
|
241
|
+
testMode: { active: true, redirectTo: "doug@x.dev" },
|
|
242
|
+
});
|
|
243
|
+
expect(line).toContain("Test mode active");
|
|
244
|
+
expect(line).toContain("doug@x.dev");
|
|
245
|
+
expect(line).toContain("pending");
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("renders a verified domain line", () => {
|
|
249
|
+
const line = renderDomainLine({
|
|
250
|
+
domain: "mysite.com",
|
|
251
|
+
status: { state: "verified" },
|
|
252
|
+
testMode: { active: false, redirectTo: null },
|
|
253
|
+
});
|
|
254
|
+
expect(line).toContain("mysite.com");
|
|
255
|
+
expect(line).toContain("verified");
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("returns null when there is nothing to say", () => {
|
|
259
|
+
expect(
|
|
260
|
+
renderDomainLine({
|
|
261
|
+
domain: null,
|
|
262
|
+
status: null,
|
|
263
|
+
testMode: { active: false, redirectTo: null },
|
|
264
|
+
}),
|
|
265
|
+
).toBeNull();
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
describe("fetchDomainLine (guarded soft-consume of /v1/admin/domain)", () => {
|
|
270
|
+
it("returns null without calling HTTP when no admin key is configured", async () => {
|
|
271
|
+
const get = vi.fn();
|
|
272
|
+
const { ctx } = makeCtx({ argv: [], get });
|
|
273
|
+
await expect(fetchDomainLine(ctx)).resolves.toBeNull();
|
|
274
|
+
expect(get).not.toHaveBeenCalled();
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it("returns null when the route 404s (engine without domain-setup)", async () => {
|
|
278
|
+
const { ctx } = makeCtx({
|
|
279
|
+
argv: [],
|
|
280
|
+
adminKey: "hsk_admin",
|
|
281
|
+
get: async () => {
|
|
282
|
+
const err = new Error("request failed with status 404");
|
|
283
|
+
(err as Error & { status: number }).status = 404;
|
|
284
|
+
throw err;
|
|
285
|
+
},
|
|
286
|
+
});
|
|
287
|
+
await expect(fetchDomainLine(ctx)).resolves.toBeNull();
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it("returns the rendered line on success", async () => {
|
|
291
|
+
const { ctx } = makeCtx({
|
|
292
|
+
argv: [],
|
|
293
|
+
adminKey: "hsk_admin",
|
|
294
|
+
get: async (path) => {
|
|
295
|
+
expect(path).toBe("/v1/admin/domain");
|
|
296
|
+
return {
|
|
297
|
+
domain: "mysite.com",
|
|
298
|
+
status: { state: "verified" },
|
|
299
|
+
testMode: { active: false, redirectTo: null },
|
|
300
|
+
};
|
|
301
|
+
},
|
|
302
|
+
});
|
|
303
|
+
const line = await fetchDomainLine(ctx);
|
|
304
|
+
expect(line).toContain("mysite.com");
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it("returns null on a malformed response body", async () => {
|
|
308
|
+
const { ctx } = makeCtx({
|
|
309
|
+
argv: [],
|
|
310
|
+
adminKey: "hsk_admin",
|
|
311
|
+
get: async () => "weird",
|
|
312
|
+
});
|
|
313
|
+
await expect(fetchDomainLine(ctx)).resolves.toBeNull();
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
describe("hogsend dev infra gating", () => {
|
|
318
|
+
it("requires package.json before doing anything else (cwd is a dir)", async () => {
|
|
319
|
+
mkdirSync(join(cwd, "sub"));
|
|
320
|
+
const { ctx } = makeCtx({ argv: ["--cwd", join(cwd, "sub")] });
|
|
321
|
+
await expect(devCommand.run(ctx)).rejects.toThrow(/package\.json/);
|
|
322
|
+
});
|
|
323
|
+
});
|