@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,437 @@
|
|
|
1
|
+
import { parseArgs } from "node:util";
|
|
2
|
+
import { confirm } from "@clack/prompts";
|
|
3
|
+
import type { DnsRecord, EngineDomainStatus } from "@hogsend/engine";
|
|
4
|
+
import { detectDnsHost, formatRecordsFor } from "../lib/dns.js";
|
|
5
|
+
import { applyRecords, canAutoApply } from "../lib/dns-apply.js";
|
|
6
|
+
import { isHttpError, type Query } from "../lib/http.js";
|
|
7
|
+
import { color } from "../lib/output.js";
|
|
8
|
+
import { bail } from "../lib/prompt.js";
|
|
9
|
+
import type { Command, CommandContext } from "./types.js";
|
|
10
|
+
|
|
11
|
+
const usage = `hogsend domain <subcommand> [options]
|
|
12
|
+
|
|
13
|
+
Manage the sending domain through the RUNNING instance's admin routes
|
|
14
|
+
(/v1/admin/domain) — provider API keys never touch the CLI. Requires an admin
|
|
15
|
+
key (--admin-key / HOGSEND_ADMIN_KEY).
|
|
16
|
+
|
|
17
|
+
Subcommands:
|
|
18
|
+
add <domain> Register the domain with the email provider, then print
|
|
19
|
+
the DNS records formatted for YOUR DNS host (detected
|
|
20
|
+
via NS lookup) with a panel deep link. When a
|
|
21
|
+
CLOUDFLARE_API_TOKEN / VERCEL_TOKEN is set, offers to
|
|
22
|
+
apply the records automatically.
|
|
23
|
+
check [<domain>] Trigger a provider verification pass, then poll status
|
|
24
|
+
every 15s until verified (exit 0) or timeout (exit 1).
|
|
25
|
+
status Show domain, provider, verification state, DNS records,
|
|
26
|
+
and the test-mode banner.
|
|
27
|
+
|
|
28
|
+
add options:
|
|
29
|
+
--apply Apply records via the DNS host API without prompting.
|
|
30
|
+
--no-apply Never apply records (skip the prompt).
|
|
31
|
+
|
|
32
|
+
check options:
|
|
33
|
+
--timeout <s> Give up after this many seconds (default 300).
|
|
34
|
+
--once Poll exactly once; exit per the current state.
|
|
35
|
+
|
|
36
|
+
status options:
|
|
37
|
+
--refresh Bypass the server-side cache (forces a provider call).
|
|
38
|
+
|
|
39
|
+
Global options (handled by the router): --url, --admin-key, --json, -h/--help.
|
|
40
|
+
|
|
41
|
+
Examples:
|
|
42
|
+
hogsend domain add mysite.com
|
|
43
|
+
hogsend domain add mysite.com --apply
|
|
44
|
+
hogsend domain check --timeout 600
|
|
45
|
+
hogsend domain status --json`;
|
|
46
|
+
|
|
47
|
+
const badge = `${color.bgMagenta(color.black(" hogsend "))} domain`;
|
|
48
|
+
|
|
49
|
+
/** Pinned domain validation regex (PROJECT_SPEC §e) — mirrors the admin route. */
|
|
50
|
+
const DOMAIN_RE = /^([a-z0-9]([a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}$/i;
|
|
51
|
+
|
|
52
|
+
/** Poll cadence for `domain check`. */
|
|
53
|
+
const POLL_INTERVAL_MS = 15_000;
|
|
54
|
+
|
|
55
|
+
const sleep = (ms: number) =>
|
|
56
|
+
new Promise<void>((resolve) => setTimeout(resolve, ms));
|
|
57
|
+
|
|
58
|
+
function getStatus(
|
|
59
|
+
ctx: CommandContext,
|
|
60
|
+
opts: { refresh?: boolean } = {},
|
|
61
|
+
): Promise<EngineDomainStatus> {
|
|
62
|
+
const query: Query | undefined = opts.refresh
|
|
63
|
+
? { refresh: "true" }
|
|
64
|
+
: undefined;
|
|
65
|
+
return ctx.http.get<EngineDomainStatus>("/v1/admin/domain", query);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Resolve the active provider id for the 501 message — best-effort via the
|
|
70
|
+
* always-200 GET; falls back to a generic label when even that fails.
|
|
71
|
+
*/
|
|
72
|
+
async function providerLabel(ctx: CommandContext): Promise<string> {
|
|
73
|
+
try {
|
|
74
|
+
const status = await getStatus(ctx);
|
|
75
|
+
return status.providerId;
|
|
76
|
+
} catch {
|
|
77
|
+
return "the active email provider";
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Map a provider_unsupported 501 (or rethrow anything else). */
|
|
82
|
+
async function failUnsupported(
|
|
83
|
+
ctx: CommandContext,
|
|
84
|
+
err: unknown,
|
|
85
|
+
): Promise<never> {
|
|
86
|
+
if (isHttpError(err) && err.status === 501) {
|
|
87
|
+
const provider = await providerLabel(ctx);
|
|
88
|
+
ctx.out.fail(
|
|
89
|
+
`provider ${provider} does not support domain management — ` +
|
|
90
|
+
"verify the domain in your provider's dashboard instead",
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
throw err;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** One status line per DNS record (the per-poll tick view). */
|
|
97
|
+
function recordTicks(records: DnsRecord[]): string {
|
|
98
|
+
return records
|
|
99
|
+
.map((r) => {
|
|
100
|
+
const tick =
|
|
101
|
+
r.status === "verified"
|
|
102
|
+
? color.green("✓")
|
|
103
|
+
: r.status === "failed"
|
|
104
|
+
? color.red("✗")
|
|
105
|
+
: color.yellow("…");
|
|
106
|
+
return ` ${tick} ${r.type.padEnd(5)} ${r.name} ${color.dim(r.status)}`;
|
|
107
|
+
})
|
|
108
|
+
.join("\n");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Human view of an EngineDomainStatus (status + check share it). */
|
|
112
|
+
function renderStatus(ctx: CommandContext, status: EngineDomainStatus): void {
|
|
113
|
+
ctx.out.kv({
|
|
114
|
+
domain: status.domain ?? "(not configured)",
|
|
115
|
+
provider: status.providerId,
|
|
116
|
+
supported: status.supported,
|
|
117
|
+
state: status.status?.state ?? "n/a",
|
|
118
|
+
checkedAt: status.status?.checkedAt ?? "",
|
|
119
|
+
});
|
|
120
|
+
const records = status.status?.records ?? [];
|
|
121
|
+
if (records.length > 0) {
|
|
122
|
+
ctx.out.log("");
|
|
123
|
+
ctx.out.table(
|
|
124
|
+
records.map((r) => ({
|
|
125
|
+
type: r.type,
|
|
126
|
+
name: r.name,
|
|
127
|
+
value: r.value,
|
|
128
|
+
priority: r.priority ?? "",
|
|
129
|
+
purpose: r.purpose,
|
|
130
|
+
status: r.status,
|
|
131
|
+
})),
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
if (status.testMode.active) {
|
|
135
|
+
ctx.out.log("");
|
|
136
|
+
ctx.out.log(
|
|
137
|
+
`${color.bgYellow(color.black(" TEST MODE "))} ${color.yellow(
|
|
138
|
+
`all sends redirect to ${status.testMode.redirectTo ?? "(no redirect address!)"}` +
|
|
139
|
+
` — reason: ${status.testMode.reason ?? "unknown"}`,
|
|
140
|
+
)}`,
|
|
141
|
+
);
|
|
142
|
+
if (!status.testMode.redirectTo) {
|
|
143
|
+
ctx.out.log(
|
|
144
|
+
color.dim(
|
|
145
|
+
" set HOGSEND_TEST_EMAIL (or STUDIO_ADMIN_EMAIL) — sends are BLOCKED until one is configured",
|
|
146
|
+
),
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
} else if (status.status?.state === "verified") {
|
|
150
|
+
// Domain verified + test mode off → sends go to real recipients.
|
|
151
|
+
ctx.out.log("");
|
|
152
|
+
ctx.out.log(`${color.green("✓")} sends live`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
// add <domain>
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
async function runAdd(ctx: CommandContext, argv: string[]): Promise<void> {
|
|
161
|
+
const { values, positionals } = parseArgs({
|
|
162
|
+
args: argv,
|
|
163
|
+
allowPositionals: true,
|
|
164
|
+
options: {
|
|
165
|
+
apply: { type: "boolean", default: false },
|
|
166
|
+
"no-apply": { type: "boolean", default: false },
|
|
167
|
+
help: { type: "boolean", short: "h", default: false },
|
|
168
|
+
},
|
|
169
|
+
});
|
|
170
|
+
if (values.help) {
|
|
171
|
+
ctx.out.log(usage);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const domain = positionals[0];
|
|
176
|
+
if (!domain) {
|
|
177
|
+
ctx.out.fail("missing <domain> — usage: hogsend domain add <domain>");
|
|
178
|
+
}
|
|
179
|
+
if (!DOMAIN_RE.test(domain)) {
|
|
180
|
+
ctx.out.fail(`invalid domain "${domain}" (expected e.g. mysite.com)`);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
ctx.out.intro(badge);
|
|
184
|
+
|
|
185
|
+
let status: EngineDomainStatus;
|
|
186
|
+
try {
|
|
187
|
+
status = await ctx.out.step(`Registering ${domain} with the provider`, () =>
|
|
188
|
+
ctx.http.post<EngineDomainStatus>("/v1/admin/domain", { domain }),
|
|
189
|
+
);
|
|
190
|
+
} catch (err) {
|
|
191
|
+
return failUnsupported(ctx, err);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const records = status.status?.records ?? [];
|
|
195
|
+
const host = await detectDnsHost(domain);
|
|
196
|
+
|
|
197
|
+
let applyResult: Awaited<ReturnType<typeof applyRecords>> | null = null;
|
|
198
|
+
const autoAvailable = canAutoApply(host.id, process.env);
|
|
199
|
+
if (autoAvailable && records.length > 0) {
|
|
200
|
+
const doApply = values.apply
|
|
201
|
+
? true
|
|
202
|
+
: values["no-apply"]
|
|
203
|
+
? false
|
|
204
|
+
: ctx.out.interactive
|
|
205
|
+
? bail(
|
|
206
|
+
await confirm({
|
|
207
|
+
message: `Apply these records via the ${host.label} API?`,
|
|
208
|
+
initialValue: true,
|
|
209
|
+
}),
|
|
210
|
+
)
|
|
211
|
+
: false; // non-TTY default: never write DNS without an explicit --apply
|
|
212
|
+
if (doApply) {
|
|
213
|
+
applyResult = await ctx.out.step(
|
|
214
|
+
`Applying ${records.length} record(s) via ${host.label}`,
|
|
215
|
+
() =>
|
|
216
|
+
applyRecords({ host: host.id, domain, records, env: process.env }),
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (ctx.json) {
|
|
222
|
+
ctx.out.json({
|
|
223
|
+
status,
|
|
224
|
+
dnsHost: host.id,
|
|
225
|
+
panelUrl: host.panelUrl(domain),
|
|
226
|
+
autoApplyAvailable: autoAvailable,
|
|
227
|
+
applied: applyResult,
|
|
228
|
+
});
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
ctx.out.log("");
|
|
233
|
+
ctx.out.log(formatRecordsFor(host, records, { domain }));
|
|
234
|
+
ctx.out.log("");
|
|
235
|
+
ctx.out.log(
|
|
236
|
+
`${color.dim("DNS panel:")} ${color.cyan(host.panelUrl(domain))}`,
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
if (applyResult) {
|
|
240
|
+
ctx.out.log("");
|
|
241
|
+
ctx.out.log(
|
|
242
|
+
`${color.green("applied")} ${applyResult.applied.length} ` +
|
|
243
|
+
`${color.yellow("skipped")} ${applyResult.skipped.length} ` +
|
|
244
|
+
`${color.red("errors")} ${applyResult.errors.length}`,
|
|
245
|
+
);
|
|
246
|
+
for (const error of applyResult.errors) {
|
|
247
|
+
ctx.out.log(` ${color.red("✗")} ${error}`);
|
|
248
|
+
}
|
|
249
|
+
} else if (autoAvailable && records.length > 0) {
|
|
250
|
+
ctx.out.log("");
|
|
251
|
+
ctx.out.log(
|
|
252
|
+
color.dim(
|
|
253
|
+
`Auto-apply available — rerun with --apply to write them via ${host.label}.`,
|
|
254
|
+
),
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
ctx.out.outro(
|
|
259
|
+
`Records added? Run ${color.cyan("hogsend domain check")} to poll verification.`,
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ---------------------------------------------------------------------------
|
|
264
|
+
// check [<domain>]
|
|
265
|
+
// ---------------------------------------------------------------------------
|
|
266
|
+
|
|
267
|
+
async function runCheck(ctx: CommandContext, argv: string[]): Promise<void> {
|
|
268
|
+
const { values, positionals } = parseArgs({
|
|
269
|
+
args: argv,
|
|
270
|
+
allowPositionals: true,
|
|
271
|
+
options: {
|
|
272
|
+
timeout: { type: "string", default: "300" },
|
|
273
|
+
once: { type: "boolean", default: false },
|
|
274
|
+
help: { type: "boolean", short: "h", default: false },
|
|
275
|
+
},
|
|
276
|
+
});
|
|
277
|
+
if (values.help) {
|
|
278
|
+
ctx.out.log(usage);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const timeoutSecs = Number(values.timeout);
|
|
283
|
+
if (!Number.isFinite(timeoutSecs) || timeoutSecs <= 0) {
|
|
284
|
+
ctx.out.fail(`invalid --timeout "${values.timeout}" (expected seconds)`);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
ctx.out.intro(badge);
|
|
288
|
+
|
|
289
|
+
// Verification always runs against the instance's CONFIGURED domain
|
|
290
|
+
// (EMAIL_DOMAIN / EMAIL_FROM) — surface a mismatch instead of silently
|
|
291
|
+
// ignoring the positional.
|
|
292
|
+
const requested = positionals[0];
|
|
293
|
+
if (requested) {
|
|
294
|
+
const current = await getStatus(ctx);
|
|
295
|
+
if (current.domain && current.domain !== requested.toLowerCase()) {
|
|
296
|
+
ctx.out.log(
|
|
297
|
+
color.yellow(
|
|
298
|
+
`note: the instance's configured sending domain is ${current.domain}; ` +
|
|
299
|
+
`checking that (not ${requested}). Set EMAIL_DOMAIN to change it.`,
|
|
300
|
+
),
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Kick a provider-side verification pass first (Resend verify; providers
|
|
306
|
+
// without one fall back to a status fetch server-side).
|
|
307
|
+
try {
|
|
308
|
+
await ctx.out.step("Triggering provider verification", () =>
|
|
309
|
+
ctx.http.post<EngineDomainStatus>("/v1/admin/domain/verify", {}),
|
|
310
|
+
);
|
|
311
|
+
} catch (err) {
|
|
312
|
+
if (isHttpError(err) && err.status === 400) {
|
|
313
|
+
ctx.out.fail(
|
|
314
|
+
"no sending domain configured — set EMAIL_DOMAIN (or EMAIL_FROM), " +
|
|
315
|
+
"or run `hogsend domain add <domain>` first",
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
return failUnsupported(ctx, err);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const deadline = Date.now() + timeoutSecs * 1000;
|
|
322
|
+
for (;;) {
|
|
323
|
+
const status = await getStatus(ctx, { refresh: true });
|
|
324
|
+
const records = status.status?.records ?? [];
|
|
325
|
+
if (records.length > 0) {
|
|
326
|
+
ctx.out.log(recordTicks(records));
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (status.status?.state === "verified") {
|
|
330
|
+
if (ctx.json) {
|
|
331
|
+
ctx.out.json(status);
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
renderStatus(ctx, status);
|
|
335
|
+
ctx.out.outro(`${color.green("Verified.")} ${status.domain} is live.`);
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (values.once) {
|
|
340
|
+
if (ctx.json) {
|
|
341
|
+
ctx.out.json(status);
|
|
342
|
+
process.exitCode = 1;
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
ctx.out.fail(
|
|
346
|
+
`domain is ${status.status?.state ?? "not configured"} (not verified)`,
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if (Date.now() + POLL_INTERVAL_MS > deadline) {
|
|
351
|
+
ctx.out.fail(
|
|
352
|
+
`timed out after ${timeoutSecs}s — DNS can take a while to propagate; ` +
|
|
353
|
+
"rerun `hogsend domain check` later",
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
ctx.out.log(
|
|
358
|
+
color.dim(
|
|
359
|
+
`state: ${status.status?.state ?? "unknown"} — polling again in 15s ...`,
|
|
360
|
+
),
|
|
361
|
+
);
|
|
362
|
+
await sleep(POLL_INTERVAL_MS);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// ---------------------------------------------------------------------------
|
|
367
|
+
// status
|
|
368
|
+
// ---------------------------------------------------------------------------
|
|
369
|
+
|
|
370
|
+
async function runStatus(ctx: CommandContext, argv: string[]): Promise<void> {
|
|
371
|
+
const { values } = parseArgs({
|
|
372
|
+
args: argv,
|
|
373
|
+
allowPositionals: false,
|
|
374
|
+
options: {
|
|
375
|
+
refresh: { type: "boolean", default: false },
|
|
376
|
+
help: { type: "boolean", short: "h", default: false },
|
|
377
|
+
},
|
|
378
|
+
});
|
|
379
|
+
if (values.help) {
|
|
380
|
+
ctx.out.log(usage);
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const status = await getStatus(ctx, { refresh: values.refresh });
|
|
385
|
+
|
|
386
|
+
if (ctx.json) {
|
|
387
|
+
ctx.out.json(status);
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
ctx.out.intro(badge);
|
|
392
|
+
renderStatus(ctx, status);
|
|
393
|
+
if (!status.supported) {
|
|
394
|
+
ctx.out.log("");
|
|
395
|
+
ctx.out.log(
|
|
396
|
+
color.dim(
|
|
397
|
+
`provider ${status.providerId} does not support domain management — ` +
|
|
398
|
+
"verify the domain in your provider's dashboard",
|
|
399
|
+
),
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
ctx.out.outro("Done.");
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// ---------------------------------------------------------------------------
|
|
406
|
+
// dispatch
|
|
407
|
+
// ---------------------------------------------------------------------------
|
|
408
|
+
|
|
409
|
+
async function run(ctx: CommandContext): Promise<void> {
|
|
410
|
+
const sub = ctx.argv[0];
|
|
411
|
+
const rest = ctx.argv.slice(1);
|
|
412
|
+
|
|
413
|
+
if (!sub || sub === "--help" || sub === "-h" || sub === "help") {
|
|
414
|
+
ctx.out.log(usage);
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
switch (sub) {
|
|
419
|
+
case "add":
|
|
420
|
+
return runAdd(ctx, rest);
|
|
421
|
+
case "check":
|
|
422
|
+
return runCheck(ctx, rest);
|
|
423
|
+
case "status":
|
|
424
|
+
return runStatus(ctx, rest);
|
|
425
|
+
default:
|
|
426
|
+
ctx.out.fail(
|
|
427
|
+
`unknown subcommand "${sub}" — expected add | check | status`,
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
export const domainCommand: Command = {
|
|
433
|
+
name: "domain",
|
|
434
|
+
summary: "Set up + verify the sending domain (DNS records, auto-apply)",
|
|
435
|
+
usage,
|
|
436
|
+
run,
|
|
437
|
+
};
|
package/src/commands/events.ts
CHANGED
|
@@ -167,7 +167,10 @@ async function runRead(ctx: CommandContext, argv: string[]): Promise<void> {
|
|
|
167
167
|
);
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
-
async function runSend(
|
|
170
|
+
export async function runSend(
|
|
171
|
+
ctx: CommandContext,
|
|
172
|
+
argv: string[],
|
|
173
|
+
): Promise<void> {
|
|
171
174
|
const { values, positionals } = parseArgs({
|
|
172
175
|
args: argv,
|
|
173
176
|
allowPositionals: true,
|
package/src/commands/index.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { campaignsCommand } from "./campaigns.js";
|
|
2
2
|
import { contactsCommand } from "./contacts.js";
|
|
3
|
+
import { devCommand } from "./dev.js";
|
|
3
4
|
import { doctorCommand } from "./doctor.js";
|
|
5
|
+
import { domainCommand } from "./domain.js";
|
|
4
6
|
import { ejectCommand } from "./eject.js";
|
|
5
7
|
import { emailsCommand } from "./emails.js";
|
|
6
8
|
import { eventsCommand } from "./events.js";
|
|
@@ -31,7 +33,9 @@ export const commands: Command[] = [
|
|
|
31
33
|
emailsCommand,
|
|
32
34
|
campaignsCommand,
|
|
33
35
|
webhooksCommand,
|
|
36
|
+
domainCommand,
|
|
34
37
|
studioCommand,
|
|
38
|
+
devCommand,
|
|
35
39
|
setupCommand,
|
|
36
40
|
skillsCommand,
|
|
37
41
|
upgradeCommand,
|
package/src/commands/setup.ts
CHANGED
|
@@ -1,11 +1,18 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { randomBytes } from "node:crypto";
|
|
3
|
-
import { copyFileSync, existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
4
2
|
import { join } from "node:path";
|
|
5
3
|
import { parseArgs } from "node:util";
|
|
6
4
|
import { confirm } from "@clack/prompts";
|
|
7
5
|
import { color } from "../lib/output.js";
|
|
8
6
|
import { bail } from "../lib/prompt.js";
|
|
7
|
+
import {
|
|
8
|
+
detectRunningInfra,
|
|
9
|
+
dockerComposeUp,
|
|
10
|
+
ensureAuthSecret,
|
|
11
|
+
ensureEnvFile,
|
|
12
|
+
hasComposeFile,
|
|
13
|
+
runMigrations,
|
|
14
|
+
type StepResult,
|
|
15
|
+
} from "../lib/setup-steps.js";
|
|
9
16
|
import type { Command, CommandContext } from "./types.js";
|
|
10
17
|
|
|
11
18
|
const usage = `hogsend setup [--cwd <dir>] [--yes] [--json]
|
|
@@ -26,139 +33,6 @@ Options:
|
|
|
26
33
|
|
|
27
34
|
Run ${color.cyan("hogsend doctor")} afterwards to verify the instance is healthy.`;
|
|
28
35
|
|
|
29
|
-
/** Generate a 64-char hex secret (32 bytes) for BETTER_AUTH_SECRET. */
|
|
30
|
-
function generateSecret(): string {
|
|
31
|
-
return randomBytes(32).toString("hex");
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
const SECRET_KEY = "BETTER_AUTH_SECRET";
|
|
35
|
-
const PLACEHOLDER_PREFIX = "change-me";
|
|
36
|
-
|
|
37
|
-
interface StepResult {
|
|
38
|
-
step: string;
|
|
39
|
-
status: "ok" | "skipped" | "failed";
|
|
40
|
-
detail: string;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* Ensure a `.env` exists (copying `.env.example` when absent) and that
|
|
45
|
-
* BETTER_AUTH_SECRET holds a real generated value rather than the placeholder.
|
|
46
|
-
* Pure-ish: only touches the filesystem, returns a structured result.
|
|
47
|
-
*/
|
|
48
|
-
function ensureEnv(cwd: string): { copied: StepResult; secret: StepResult } {
|
|
49
|
-
const envPath = join(cwd, ".env");
|
|
50
|
-
const examplePath = join(cwd, ".env.example");
|
|
51
|
-
|
|
52
|
-
let copied: StepResult;
|
|
53
|
-
if (existsSync(envPath)) {
|
|
54
|
-
copied = {
|
|
55
|
-
step: "env",
|
|
56
|
-
status: "skipped",
|
|
57
|
-
detail: ".env already exists",
|
|
58
|
-
};
|
|
59
|
-
} else if (existsSync(examplePath)) {
|
|
60
|
-
copyFileSync(examplePath, envPath);
|
|
61
|
-
copied = {
|
|
62
|
-
step: "env",
|
|
63
|
-
status: "ok",
|
|
64
|
-
detail: "copied .env.example -> .env",
|
|
65
|
-
};
|
|
66
|
-
} else {
|
|
67
|
-
copied = {
|
|
68
|
-
step: "env",
|
|
69
|
-
status: "failed",
|
|
70
|
-
detail: "no .env and no .env.example to copy from",
|
|
71
|
-
};
|
|
72
|
-
return {
|
|
73
|
-
copied,
|
|
74
|
-
secret: {
|
|
75
|
-
step: "secret",
|
|
76
|
-
status: "skipped",
|
|
77
|
-
detail: "skipped — no .env",
|
|
78
|
-
},
|
|
79
|
-
};
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
// (Re)read the file we just ensured exists and refresh the secret if it is
|
|
83
|
-
// missing or still the scaffold placeholder. Never overwrite a real secret.
|
|
84
|
-
let raw: string;
|
|
85
|
-
try {
|
|
86
|
-
raw = readFileSync(envPath, "utf8");
|
|
87
|
-
} catch (err) {
|
|
88
|
-
return {
|
|
89
|
-
copied,
|
|
90
|
-
secret: {
|
|
91
|
-
step: "secret",
|
|
92
|
-
status: "failed",
|
|
93
|
-
detail: `could not read .env: ${err instanceof Error ? err.message : String(err)}`,
|
|
94
|
-
},
|
|
95
|
-
};
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
const lines = raw.split(/\r?\n/);
|
|
99
|
-
const idx = lines.findIndex((l) =>
|
|
100
|
-
l
|
|
101
|
-
.replace(/^export\s+/, "")
|
|
102
|
-
.trimStart()
|
|
103
|
-
.startsWith(`${SECRET_KEY}=`),
|
|
104
|
-
);
|
|
105
|
-
const existingLine = idx === -1 ? undefined : lines[idx];
|
|
106
|
-
const current =
|
|
107
|
-
existingLine === undefined
|
|
108
|
-
? undefined
|
|
109
|
-
: existingLine.slice(existingLine.indexOf("=") + 1).trim();
|
|
110
|
-
const isPlaceholder =
|
|
111
|
-
current === undefined ||
|
|
112
|
-
current === "" ||
|
|
113
|
-
current.startsWith(PLACEHOLDER_PREFIX);
|
|
114
|
-
|
|
115
|
-
if (!isPlaceholder) {
|
|
116
|
-
return {
|
|
117
|
-
copied,
|
|
118
|
-
secret: {
|
|
119
|
-
step: "secret",
|
|
120
|
-
status: "skipped",
|
|
121
|
-
detail: `${SECRET_KEY} already set`,
|
|
122
|
-
},
|
|
123
|
-
};
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
const secret = generateSecret();
|
|
127
|
-
const newLine = `${SECRET_KEY}=${secret}`;
|
|
128
|
-
if (idx === -1) {
|
|
129
|
-
if (raw.length > 0 && !raw.endsWith("\n")) lines.push("");
|
|
130
|
-
lines.push(newLine);
|
|
131
|
-
} else {
|
|
132
|
-
lines[idx] = newLine;
|
|
133
|
-
}
|
|
134
|
-
writeFileSync(envPath, lines.join("\n"));
|
|
135
|
-
|
|
136
|
-
return {
|
|
137
|
-
copied,
|
|
138
|
-
secret: {
|
|
139
|
-
step: "secret",
|
|
140
|
-
status: "ok",
|
|
141
|
-
detail: `generated ${SECRET_KEY} (64-char hex)`,
|
|
142
|
-
},
|
|
143
|
-
};
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
/** Run a shell command, capturing exit status. */
|
|
147
|
-
function runCmd(
|
|
148
|
-
cmd: string,
|
|
149
|
-
args: string[],
|
|
150
|
-
cwd: string,
|
|
151
|
-
json: boolean,
|
|
152
|
-
): { status: number | null; ok: boolean } {
|
|
153
|
-
const result = spawnSync(cmd, args, {
|
|
154
|
-
cwd,
|
|
155
|
-
// In json mode stay silent (we report structured status); otherwise stream
|
|
156
|
-
// so the user sees docker / migration output inline.
|
|
157
|
-
stdio: json ? "ignore" : "inherit",
|
|
158
|
-
});
|
|
159
|
-
return { status: result.status, ok: result.status === 0 };
|
|
160
|
-
}
|
|
161
|
-
|
|
162
36
|
async function run(ctx: CommandContext): Promise<void> {
|
|
163
37
|
const { values } = parseArgs({
|
|
164
38
|
args: ctx.argv,
|
|
@@ -183,11 +57,7 @@ async function run(ctx: CommandContext): Promise<void> {
|
|
|
183
57
|
);
|
|
184
58
|
}
|
|
185
59
|
|
|
186
|
-
const hasCompose =
|
|
187
|
-
existsSync(join(cwd, "docker-compose.yml")) ||
|
|
188
|
-
existsSync(join(cwd, "docker-compose.yaml")) ||
|
|
189
|
-
existsSync(join(cwd, "compose.yml")) ||
|
|
190
|
-
existsSync(join(cwd, "compose.yaml"));
|
|
60
|
+
const hasCompose = hasComposeFile(cwd);
|
|
191
61
|
|
|
192
62
|
// --json implies non-interactive; in TTY human mode we confirm first.
|
|
193
63
|
const skipConfirm = ctx.json || values.yes;
|
|
@@ -212,19 +82,25 @@ async function run(ctx: CommandContext): Promise<void> {
|
|
|
212
82
|
|
|
213
83
|
const results: StepResult[] = [];
|
|
214
84
|
|
|
215
|
-
// 1. docker compose up -d
|
|
85
|
+
// 1. docker compose up -d — but skip the (slow, noisy) compose call when
|
|
86
|
+
// detection shows the whole trio is already running (no double-start).
|
|
216
87
|
if (hasCompose) {
|
|
217
|
-
const
|
|
218
|
-
|
|
219
|
-
async () => runCmd("docker", ["compose", "up", "-d"], cwd, ctx.json),
|
|
88
|
+
const infra = await ctx.out.step("Checking infra", async () =>
|
|
89
|
+
detectRunningInfra(cwd),
|
|
220
90
|
);
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
}
|
|
91
|
+
if (infra.postgres && infra.redis && infra.hatchet) {
|
|
92
|
+
results.push({
|
|
93
|
+
step: "docker",
|
|
94
|
+
status: "skipped",
|
|
95
|
+
detail: "infra already running",
|
|
96
|
+
});
|
|
97
|
+
} else {
|
|
98
|
+
const docker = await ctx.out.step(
|
|
99
|
+
"Starting infra (docker compose up -d)",
|
|
100
|
+
async () => dockerComposeUp(cwd, { quiet: ctx.json }),
|
|
101
|
+
);
|
|
102
|
+
results.push(docker);
|
|
103
|
+
}
|
|
228
104
|
} else {
|
|
229
105
|
results.push({
|
|
230
106
|
step: "docker",
|
|
@@ -234,9 +110,10 @@ async function run(ctx: CommandContext): Promise<void> {
|
|
|
234
110
|
}
|
|
235
111
|
|
|
236
112
|
// 2 + 3. .env + secret (synchronous fs work, wrapped in a step for the spinner)
|
|
237
|
-
const env = await ctx.out.step("Preparing .env + auth secret", async () =>
|
|
238
|
-
|
|
239
|
-
|
|
113
|
+
const env = await ctx.out.step("Preparing .env + auth secret", async () => ({
|
|
114
|
+
copied: ensureEnvFile(cwd),
|
|
115
|
+
secret: ensureAuthSecret(cwd),
|
|
116
|
+
}));
|
|
240
117
|
results.push(env.copied, env.secret);
|
|
241
118
|
|
|
242
119
|
// 4. db:migrate (only attempt if docker didn't hard-fail; still try if skipped)
|
|
@@ -253,15 +130,9 @@ async function run(ctx: CommandContext): Promise<void> {
|
|
|
253
130
|
} else {
|
|
254
131
|
const migrate = await ctx.out.step(
|
|
255
132
|
"Running migrations (pnpm db:migrate)",
|
|
256
|
-
async () =>
|
|
133
|
+
async () => runMigrations(cwd, { quiet: ctx.json }),
|
|
257
134
|
);
|
|
258
|
-
results.push(
|
|
259
|
-
step: "migrate",
|
|
260
|
-
status: migrate.ok ? "ok" : "failed",
|
|
261
|
-
detail: migrate.ok
|
|
262
|
-
? "engine + client migrations applied"
|
|
263
|
-
: `pnpm db:migrate exited with code ${migrate.status ?? "?"}`,
|
|
264
|
-
});
|
|
135
|
+
results.push(migrate);
|
|
265
136
|
}
|
|
266
137
|
|
|
267
138
|
const failed = results.filter((r) => r.status === "failed");
|