@hogsend/cli 0.10.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.
- package/dist/bin.js +13517 -1754
- package/dist/bin.js.map +1 -1
- package/package.json +6 -3
- package/skills/hogsend-authoring-emails/references/tracking-and-unsubscribe.md +5 -4
- package/skills/hogsend-cli/SKILL.md +32 -1
- package/skills/hogsend-extending/SKILL.md +4 -1
- package/skills/hogsend-extending/references/swap-a-provider.md +273 -51
- 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__/admin-recovery.test.ts +193 -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/bin.ts +13 -3
- 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/commands/studio-admin.ts +340 -0
- package/src/commands/studio.ts +17 -1
- package/src/lib/admin-recovery.ts +193 -0
- 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-BNDE5JtQ.css +0 -1
- package/studio/assets/index-CgJBk-Ft.js +0 -250
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import type { DnsRecord } from "@hogsend/engine";
|
|
2
|
+
import type { DnsHostId } from "./dns.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Best-effort DNS auto-apply for `hogsend domain add`. Supported hosts:
|
|
6
|
+
* Cloudflare (CLOUDFLARE_API_TOKEN) and Vercel (VERCEL_TOKEN [+ VERCEL_TEAM_ID]).
|
|
7
|
+
* These are CLI-PROCESS env vars only — deliberately NOT part of the engine's
|
|
8
|
+
* validated env (the engine never writes DNS).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** True when the host supports auto-apply AND its credential is present. */
|
|
12
|
+
export function canAutoApply(host: DnsHostId, env: NodeJS.ProcessEnv): boolean {
|
|
13
|
+
switch (host) {
|
|
14
|
+
case "cloudflare":
|
|
15
|
+
return Boolean(env.CLOUDFLARE_API_TOKEN);
|
|
16
|
+
case "vercel":
|
|
17
|
+
return Boolean(env.VERCEL_TOKEN);
|
|
18
|
+
default:
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ApplyRecordsOptions {
|
|
24
|
+
host: DnsHostId;
|
|
25
|
+
domain: string;
|
|
26
|
+
records: DnsRecord[];
|
|
27
|
+
env: NodeJS.ProcessEnv;
|
|
28
|
+
/** Injectable fetch for tests — NEVER hit the real APIs in CI. */
|
|
29
|
+
fetchImpl?: typeof fetch;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ApplyRecordsResult {
|
|
33
|
+
applied: DnsRecord[];
|
|
34
|
+
skipped: DnsRecord[];
|
|
35
|
+
errors: string[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Cloudflare error codes meaning "an identical record already exists". */
|
|
39
|
+
const CF_DUPLICATE_CODES = new Set([81057, 81053]);
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Registrable domain heuristic for zone lookup: the last two labels. Good
|
|
43
|
+
* enough for the auto-apply happy path (mail.mysite.com → mysite.com); exotic
|
|
44
|
+
* public-suffix domains fall back to the error path and manual records.
|
|
45
|
+
*/
|
|
46
|
+
function registrableDomain(domain: string): string {
|
|
47
|
+
const labels = domain.split(".").filter(Boolean);
|
|
48
|
+
if (labels.length <= 2) return domain;
|
|
49
|
+
return labels.slice(-2).join(".");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function parseJson(res: Response): Promise<unknown> {
|
|
53
|
+
try {
|
|
54
|
+
return await res.json();
|
|
55
|
+
} catch {
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function applyCloudflare(
|
|
61
|
+
opts: ApplyRecordsOptions,
|
|
62
|
+
fetchImpl: typeof fetch,
|
|
63
|
+
): Promise<ApplyRecordsResult> {
|
|
64
|
+
const result: ApplyRecordsResult = { applied: [], skipped: [], errors: [] };
|
|
65
|
+
const token = opts.env.CLOUDFLARE_API_TOKEN ?? "";
|
|
66
|
+
const headers = {
|
|
67
|
+
Authorization: `Bearer ${token}`,
|
|
68
|
+
"Content-Type": "application/json",
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// Resolve the zone id for the registrable domain.
|
|
72
|
+
const zoneName = registrableDomain(opts.domain);
|
|
73
|
+
let zoneId: string | undefined;
|
|
74
|
+
try {
|
|
75
|
+
const res = await fetchImpl(
|
|
76
|
+
`https://api.cloudflare.com/client/v4/zones?name=${encodeURIComponent(zoneName)}`,
|
|
77
|
+
{ headers },
|
|
78
|
+
);
|
|
79
|
+
const body = (await parseJson(res)) as
|
|
80
|
+
| { result?: Array<{ id?: string }> }
|
|
81
|
+
| undefined;
|
|
82
|
+
zoneId = body?.result?.[0]?.id;
|
|
83
|
+
} catch (cause) {
|
|
84
|
+
// `skipped` is reserved for "already present" — a zone failure means
|
|
85
|
+
// NOTHING was attempted, so it surfaces as a single error.
|
|
86
|
+
result.errors.push(
|
|
87
|
+
`Cloudflare zone lookup failed: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
88
|
+
);
|
|
89
|
+
return result;
|
|
90
|
+
}
|
|
91
|
+
if (!zoneId) {
|
|
92
|
+
result.errors.push(
|
|
93
|
+
`could not resolve a Cloudflare zone for ${zoneName} — is the domain on this account?`,
|
|
94
|
+
);
|
|
95
|
+
return result;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
for (const record of opts.records) {
|
|
99
|
+
try {
|
|
100
|
+
const res = await fetchImpl(
|
|
101
|
+
`https://api.cloudflare.com/client/v4/zones/${zoneId}/dns_records`,
|
|
102
|
+
{
|
|
103
|
+
method: "POST",
|
|
104
|
+
headers,
|
|
105
|
+
body: JSON.stringify({
|
|
106
|
+
type: record.type,
|
|
107
|
+
name: record.name,
|
|
108
|
+
content: record.value,
|
|
109
|
+
ttl: record.ttl ?? 1, // 1 = automatic
|
|
110
|
+
...(record.priority !== undefined
|
|
111
|
+
? { priority: record.priority }
|
|
112
|
+
: {}),
|
|
113
|
+
// NEVER proxy mail-verification records — orange-cloud breaks them.
|
|
114
|
+
proxied: false,
|
|
115
|
+
}),
|
|
116
|
+
},
|
|
117
|
+
);
|
|
118
|
+
if (res.ok) {
|
|
119
|
+
result.applied.push(record);
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
const body = (await parseJson(res)) as
|
|
123
|
+
| { errors?: Array<{ code?: number; message?: string }> }
|
|
124
|
+
| undefined;
|
|
125
|
+
const codes = (body?.errors ?? []).map((e) => e.code ?? 0);
|
|
126
|
+
if (codes.some((code) => CF_DUPLICATE_CODES.has(code))) {
|
|
127
|
+
// Identical record already present — idempotent success.
|
|
128
|
+
result.skipped.push(record);
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
const message =
|
|
132
|
+
body?.errors?.[0]?.message ?? `Cloudflare API status ${res.status}`;
|
|
133
|
+
result.errors.push(`${record.type} ${record.name}: ${message}`);
|
|
134
|
+
} catch (cause) {
|
|
135
|
+
result.errors.push(
|
|
136
|
+
`${record.type} ${record.name}: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return result;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function applyVercel(
|
|
144
|
+
opts: ApplyRecordsOptions,
|
|
145
|
+
fetchImpl: typeof fetch,
|
|
146
|
+
): Promise<ApplyRecordsResult> {
|
|
147
|
+
const result: ApplyRecordsResult = { applied: [], skipped: [], errors: [] };
|
|
148
|
+
const token = opts.env.VERCEL_TOKEN ?? "";
|
|
149
|
+
const teamId = opts.env.VERCEL_TEAM_ID;
|
|
150
|
+
const base = `https://api.vercel.com/v2/domains/${encodeURIComponent(opts.domain)}/records`;
|
|
151
|
+
const url = teamId ? `${base}?teamId=${encodeURIComponent(teamId)}` : base;
|
|
152
|
+
|
|
153
|
+
for (const record of opts.records) {
|
|
154
|
+
try {
|
|
155
|
+
const res = await fetchImpl(url, {
|
|
156
|
+
method: "POST",
|
|
157
|
+
headers: {
|
|
158
|
+
Authorization: `Bearer ${token}`,
|
|
159
|
+
"Content-Type": "application/json",
|
|
160
|
+
},
|
|
161
|
+
body: JSON.stringify({
|
|
162
|
+
type: record.type,
|
|
163
|
+
name: record.name,
|
|
164
|
+
value: record.value,
|
|
165
|
+
...(record.ttl !== undefined ? { ttl: record.ttl } : {}),
|
|
166
|
+
...(record.priority !== undefined
|
|
167
|
+
? { mxPriority: record.priority }
|
|
168
|
+
: {}),
|
|
169
|
+
}),
|
|
170
|
+
});
|
|
171
|
+
if (res.ok) {
|
|
172
|
+
result.applied.push(record);
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
const body = (await parseJson(res)) as
|
|
176
|
+
| { error?: { code?: string; message?: string } }
|
|
177
|
+
| undefined;
|
|
178
|
+
const code = body?.error?.code ?? "";
|
|
179
|
+
const message = body?.error?.message ?? "";
|
|
180
|
+
if (/duplicate/i.test(code) || /already exists/i.test(message)) {
|
|
181
|
+
result.skipped.push(record);
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
result.errors.push(
|
|
185
|
+
`${record.type} ${record.name}: ${message || `Vercel API status ${res.status}`}`,
|
|
186
|
+
);
|
|
187
|
+
} catch (cause) {
|
|
188
|
+
result.errors.push(
|
|
189
|
+
`${record.type} ${record.name}: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return result;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Apply the DNS records via the host's API. NEVER throws — failures land in
|
|
198
|
+
* `errors`, "identical record exists" responses land in `skipped`, so the
|
|
199
|
+
* caller can always render a complete applied/skipped/errors report.
|
|
200
|
+
*/
|
|
201
|
+
export async function applyRecords(
|
|
202
|
+
opts: ApplyRecordsOptions,
|
|
203
|
+
): Promise<ApplyRecordsResult> {
|
|
204
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
205
|
+
|
|
206
|
+
if (!canAutoApply(opts.host, opts.env)) {
|
|
207
|
+
return {
|
|
208
|
+
applied: [],
|
|
209
|
+
skipped: [...opts.records],
|
|
210
|
+
errors: [
|
|
211
|
+
`auto-apply is not available for ${opts.host} — add the records manually in your DNS panel`,
|
|
212
|
+
],
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (opts.host === "cloudflare") return applyCloudflare(opts, fetchImpl);
|
|
217
|
+
return applyVercel(opts, fetchImpl);
|
|
218
|
+
}
|
package/src/lib/dns.ts
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { resolveNs as nodeResolveNs } from "node:dns/promises";
|
|
2
|
+
import type { DnsRecord } from "@hogsend/engine";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* DNS-host smarts for `hogsend domain`: detect WHERE a domain's DNS lives (via
|
|
6
|
+
* its NS records) and render provider DNS records with host-specific guidance
|
|
7
|
+
* + a panel deep link. Pure CLI-local helpers — no engine/provider coupling
|
|
8
|
+
* beyond the neutral {@link DnsRecord} shape.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export type DnsHostId =
|
|
12
|
+
| "cloudflare"
|
|
13
|
+
| "vercel"
|
|
14
|
+
| "route53"
|
|
15
|
+
| "godaddy"
|
|
16
|
+
| "namecheap"
|
|
17
|
+
| "porkbun"
|
|
18
|
+
| "google"
|
|
19
|
+
| "unknown";
|
|
20
|
+
|
|
21
|
+
export interface DnsHostInfo {
|
|
22
|
+
id: DnsHostId;
|
|
23
|
+
/** Human label, e.g. "Cloudflare". */
|
|
24
|
+
label: string;
|
|
25
|
+
/** Deep link to the host's DNS panel for the domain. */
|
|
26
|
+
panelUrl: (domain: string) => string;
|
|
27
|
+
/** NS hostname suffixes that identify this host. */
|
|
28
|
+
nsSuffixes: string[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The known DNS hosts, keyed by id. `unknown` is the registrar-agnostic fallback. */
|
|
32
|
+
export const DNS_HOSTS: Record<DnsHostId, DnsHostInfo> = {
|
|
33
|
+
cloudflare: {
|
|
34
|
+
id: "cloudflare",
|
|
35
|
+
label: "Cloudflare",
|
|
36
|
+
nsSuffixes: ["ns.cloudflare.com"],
|
|
37
|
+
panelUrl: (domain) =>
|
|
38
|
+
`https://dash.cloudflare.com/?to=/:account/${domain}/dns/records`,
|
|
39
|
+
},
|
|
40
|
+
vercel: {
|
|
41
|
+
id: "vercel",
|
|
42
|
+
label: "Vercel",
|
|
43
|
+
nsSuffixes: ["vercel-dns.com"],
|
|
44
|
+
panelUrl: (domain) => `https://vercel.com/dashboard/domains/${domain}`,
|
|
45
|
+
},
|
|
46
|
+
route53: {
|
|
47
|
+
id: "route53",
|
|
48
|
+
label: "AWS Route 53",
|
|
49
|
+
nsSuffixes: ["awsdns-"],
|
|
50
|
+
panelUrl: () => "https://console.aws.amazon.com/route53/v2/hostedzones",
|
|
51
|
+
},
|
|
52
|
+
godaddy: {
|
|
53
|
+
id: "godaddy",
|
|
54
|
+
label: "GoDaddy",
|
|
55
|
+
nsSuffixes: ["domaincontrol.com"],
|
|
56
|
+
panelUrl: (domain) =>
|
|
57
|
+
`https://dcc.godaddy.com/control/portfolio/${domain}/settings`,
|
|
58
|
+
},
|
|
59
|
+
namecheap: {
|
|
60
|
+
id: "namecheap",
|
|
61
|
+
label: "Namecheap",
|
|
62
|
+
nsSuffixes: ["registrar-servers.com"],
|
|
63
|
+
panelUrl: (domain) =>
|
|
64
|
+
`https://ap.www.namecheap.com/domains/domaincontrolpanel/${domain}/advancedns`,
|
|
65
|
+
},
|
|
66
|
+
porkbun: {
|
|
67
|
+
id: "porkbun",
|
|
68
|
+
label: "Porkbun",
|
|
69
|
+
nsSuffixes: ["porkbun.com"],
|
|
70
|
+
panelUrl: (domain) => `https://porkbun.com/account/domain/${domain}`,
|
|
71
|
+
},
|
|
72
|
+
google: {
|
|
73
|
+
id: "google",
|
|
74
|
+
label: "Google Domains",
|
|
75
|
+
nsSuffixes: ["googledomains.com", "google.com"],
|
|
76
|
+
panelUrl: () => "https://domains.google.com/registrar",
|
|
77
|
+
},
|
|
78
|
+
unknown: {
|
|
79
|
+
id: "unknown",
|
|
80
|
+
label: "your DNS host",
|
|
81
|
+
nsSuffixes: [],
|
|
82
|
+
panelUrl: (domain) => `https://${domain}`,
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/** Injectable seams for tests (NEVER hit real DNS in CI). */
|
|
87
|
+
export interface DetectDnsHostOptions {
|
|
88
|
+
resolveNs?: (hostname: string) => Promise<string[]>;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Detect a domain's DNS host by resolving its NS records and suffix-matching
|
|
93
|
+
* against {@link DNS_HOSTS}. Walks UP the labels (`a.b.example.com` →
|
|
94
|
+
* `b.example.com` → `example.com`) until NS records resolve, so subdomains
|
|
95
|
+
* inherit their registrable domain's host. Any resolver failure → `unknown`
|
|
96
|
+
* (this NEVER throws — host detection is best-effort UX, not a gate).
|
|
97
|
+
*/
|
|
98
|
+
export async function detectDnsHost(
|
|
99
|
+
domain: string,
|
|
100
|
+
opts: DetectDnsHostOptions = {},
|
|
101
|
+
): Promise<DnsHostInfo> {
|
|
102
|
+
const resolveNs = opts.resolveNs ?? nodeResolveNs;
|
|
103
|
+
|
|
104
|
+
const labels = domain.toLowerCase().split(".").filter(Boolean);
|
|
105
|
+
for (let i = 0; i <= labels.length - 2; i++) {
|
|
106
|
+
const candidate = labels.slice(i).join(".");
|
|
107
|
+
let nameservers: string[];
|
|
108
|
+
try {
|
|
109
|
+
nameservers = await resolveNs(candidate);
|
|
110
|
+
} catch {
|
|
111
|
+
continue; // walk up one label and retry
|
|
112
|
+
}
|
|
113
|
+
if (nameservers.length === 0) continue;
|
|
114
|
+
|
|
115
|
+
const lowered = nameservers.map((ns) => ns.toLowerCase());
|
|
116
|
+
for (const host of Object.values(DNS_HOSTS)) {
|
|
117
|
+
if (host.id === "unknown") continue;
|
|
118
|
+
const matched = host.nsSuffixes.some((suffix) =>
|
|
119
|
+
lowered.some((ns) => ns.includes(suffix)),
|
|
120
|
+
);
|
|
121
|
+
if (matched) return host;
|
|
122
|
+
}
|
|
123
|
+
// NS resolved but nothing matched — it's a host we don't know.
|
|
124
|
+
return DNS_HOSTS.unknown;
|
|
125
|
+
}
|
|
126
|
+
return DNS_HOSTS.unknown;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Hosts whose DNS panels expect the record host RELATIVE to the domain. */
|
|
130
|
+
const RELATIVE_HOST_IDS = new Set<DnsHostId>(["namecheap", "godaddy"]);
|
|
131
|
+
|
|
132
|
+
/** Strip a trailing `.domain` (or bare-domain → `@`) for relative-host panels. */
|
|
133
|
+
function relativeName(name: string, domain: string): string {
|
|
134
|
+
const lowered = name.toLowerCase();
|
|
135
|
+
const suffix = `.${domain.toLowerCase()}`;
|
|
136
|
+
if (lowered === domain.toLowerCase()) return "@";
|
|
137
|
+
if (lowered.endsWith(suffix))
|
|
138
|
+
return name.slice(0, name.length - suffix.length);
|
|
139
|
+
return name;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function renderTable(rows: string[][], header: string[]): string {
|
|
143
|
+
const all = [header, ...rows];
|
|
144
|
+
const widths = header.map((_, col) =>
|
|
145
|
+
Math.max(...all.map((row) => (row[col] ?? "").length)),
|
|
146
|
+
);
|
|
147
|
+
const line = (row: string[]) =>
|
|
148
|
+
row
|
|
149
|
+
.map((cell, col) => cell.padEnd(widths[col] ?? 0))
|
|
150
|
+
.join(" ")
|
|
151
|
+
.trimEnd();
|
|
152
|
+
return [
|
|
153
|
+
line(header),
|
|
154
|
+
line(widths.map((w) => "-".repeat(w))),
|
|
155
|
+
...rows.map(line),
|
|
156
|
+
].join("\n");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Per-host guidance lines appended under the record table. */
|
|
160
|
+
function hostGuidance(host: DnsHostInfo, domain: string | undefined): string[] {
|
|
161
|
+
switch (host.id) {
|
|
162
|
+
case "cloudflare":
|
|
163
|
+
return [
|
|
164
|
+
"Cloudflare: set Proxy status to DNS only (grey cloud) on every record —",
|
|
165
|
+
"proxied (orange-cloud) records break email verification.",
|
|
166
|
+
];
|
|
167
|
+
case "namecheap":
|
|
168
|
+
case "godaddy":
|
|
169
|
+
return [
|
|
170
|
+
`${host.label}: enter the host RELATIVE to ${domain ?? "your domain"} (the table above is already relative).`,
|
|
171
|
+
];
|
|
172
|
+
case "vercel":
|
|
173
|
+
return [
|
|
174
|
+
"Vercel: add the records under the domain's DNS tab (they apply instantly).",
|
|
175
|
+
];
|
|
176
|
+
default:
|
|
177
|
+
return [
|
|
178
|
+
"Add these records in your DNS host's panel exactly as shown, then run",
|
|
179
|
+
"`hogsend domain check` to poll verification.",
|
|
180
|
+
];
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Render the DNS records as an aligned `type/name/value/priority/status` table
|
|
186
|
+
* with host-specific guidance. For relative-host panels (Namecheap, GoDaddy)
|
|
187
|
+
* the record names are stripped to be relative to `opts.domain` (skipped when
|
|
188
|
+
* the domain isn't supplied — the pinned two-arg call stays valid).
|
|
189
|
+
*/
|
|
190
|
+
export function formatRecordsFor(
|
|
191
|
+
host: DnsHostInfo,
|
|
192
|
+
records: DnsRecord[],
|
|
193
|
+
opts: { domain?: string } = {},
|
|
194
|
+
): string {
|
|
195
|
+
if (records.length === 0) {
|
|
196
|
+
return "No DNS records reported by the provider yet.";
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const relative = RELATIVE_HOST_IDS.has(host.id) && opts.domain !== undefined;
|
|
200
|
+
const rows = records.map((r) => [
|
|
201
|
+
r.type,
|
|
202
|
+
relative && opts.domain ? relativeName(r.name, opts.domain) : r.name,
|
|
203
|
+
r.value,
|
|
204
|
+
r.priority !== undefined ? String(r.priority) : "",
|
|
205
|
+
r.status,
|
|
206
|
+
]);
|
|
207
|
+
|
|
208
|
+
const table = renderTable(rows, [
|
|
209
|
+
"type",
|
|
210
|
+
"name",
|
|
211
|
+
"value",
|
|
212
|
+
"priority",
|
|
213
|
+
"status",
|
|
214
|
+
]);
|
|
215
|
+
|
|
216
|
+
return [table, "", ...hostGuidance(host, opts.domain)].join("\n");
|
|
217
|
+
}
|
package/src/lib/proc.ts
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { type ChildProcess, spawn } from "node:child_process";
|
|
2
|
+
import { createInterface } from "node:readline";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Process orchestration primitives for `hogsend dev`.
|
|
6
|
+
*
|
|
7
|
+
* The contract here is pinned (PROJECT_SPEC §d): `spawnManaged`,
|
|
8
|
+
* `shutdownAll`, `waitForHttp`. Everything else is additive.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** How a managed child finished. */
|
|
12
|
+
export interface ProcessExit {
|
|
13
|
+
code: number | null;
|
|
14
|
+
signal: NodeJS.Signals | null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ManagedProcess {
|
|
18
|
+
name: string;
|
|
19
|
+
child: ChildProcess;
|
|
20
|
+
/** Resolves once the child has fully exited (additive to the pinned contract). */
|
|
21
|
+
exited: Promise<ProcessExit>;
|
|
22
|
+
/** Register a callback fired once on exit; fires immediately if already exited. */
|
|
23
|
+
onExit(cb: (info: ProcessExit) => void): void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Spawn a long-running child with line-prefixed stdio.
|
|
28
|
+
*
|
|
29
|
+
* On POSIX the child is spawned `detached`, making it its own process-group
|
|
30
|
+
* leader. That is the no-orphans mechanism: (a) the terminal's Ctrl-C SIGINT
|
|
31
|
+
* hits only our process (not the children), so the orderly shutdown can't be
|
|
32
|
+
* raced, and (b) {@link shutdownAll} can kill the whole tree (`pnpm` →
|
|
33
|
+
* `tsx watch` → app) with a negative-pid group kill.
|
|
34
|
+
*/
|
|
35
|
+
export function spawnManaged(opts: {
|
|
36
|
+
name: string;
|
|
37
|
+
cmd: string;
|
|
38
|
+
args: string[];
|
|
39
|
+
cwd: string;
|
|
40
|
+
env?: Record<string, string>;
|
|
41
|
+
prefixColor: (s: string) => string;
|
|
42
|
+
/**
|
|
43
|
+
* Optional line sink (additive — exists so tests can capture output).
|
|
44
|
+
* Defaults to process.stdout/stderr writes.
|
|
45
|
+
*/
|
|
46
|
+
sink?: (line: string) => void;
|
|
47
|
+
}): ManagedProcess {
|
|
48
|
+
const child = spawn(opts.cmd, opts.args, {
|
|
49
|
+
cwd: opts.cwd,
|
|
50
|
+
env: { ...process.env, FORCE_COLOR: "1", ...opts.env },
|
|
51
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
52
|
+
detached: process.platform !== "win32",
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const prefix = opts.prefixColor(`[${opts.name}]`);
|
|
56
|
+
const writeOut = opts.sink ?? ((line: string) => process.stdout.write(line));
|
|
57
|
+
const writeErr = opts.sink ?? ((line: string) => process.stderr.write(line));
|
|
58
|
+
|
|
59
|
+
if (child.stdout) {
|
|
60
|
+
const rl = createInterface({ input: child.stdout });
|
|
61
|
+
rl.on("line", (line) => writeOut(`${prefix} ${line}\n`));
|
|
62
|
+
}
|
|
63
|
+
if (child.stderr) {
|
|
64
|
+
const rl = createInterface({ input: child.stderr });
|
|
65
|
+
rl.on("line", (line) => writeErr(`${prefix} ${line}\n`));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const callbacks: Array<(info: ProcessExit) => void> = [];
|
|
69
|
+
let exitInfo: ProcessExit | null = null;
|
|
70
|
+
const exited = new Promise<ProcessExit>((resolve) => {
|
|
71
|
+
const settle = (info: ProcessExit) => {
|
|
72
|
+
if (exitInfo) return;
|
|
73
|
+
exitInfo = info;
|
|
74
|
+
resolve(info);
|
|
75
|
+
for (const cb of callbacks) cb(info);
|
|
76
|
+
};
|
|
77
|
+
child.once("close", (code, signal) => settle({ code, signal }));
|
|
78
|
+
// A spawn failure (e.g. command not found) may never emit `close`.
|
|
79
|
+
child.once("error", (err) => {
|
|
80
|
+
writeErr(`${prefix} failed to start: ${err.message}\n`);
|
|
81
|
+
settle({ code: null, signal: null });
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
name: opts.name,
|
|
87
|
+
child,
|
|
88
|
+
exited,
|
|
89
|
+
onExit(cb) {
|
|
90
|
+
if (exitInfo) {
|
|
91
|
+
cb(exitInfo);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
callbacks.push(cb);
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function hasExited(proc: ManagedProcess): boolean {
|
|
100
|
+
return (
|
|
101
|
+
proc.child.pid === undefined ||
|
|
102
|
+
proc.child.exitCode !== null ||
|
|
103
|
+
proc.child.signalCode !== null
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Send `signal` to the child's whole process group (POSIX), falling back to a
|
|
109
|
+
* direct kill on Windows or when the group kill fails. Swallows ESRCH
|
|
110
|
+
* (already dead).
|
|
111
|
+
*/
|
|
112
|
+
function killTree(proc: ManagedProcess, signal: NodeJS.Signals): void {
|
|
113
|
+
const pid = proc.child.pid;
|
|
114
|
+
if (pid === undefined || hasExited(proc)) return;
|
|
115
|
+
try {
|
|
116
|
+
if (process.platform !== "win32") {
|
|
117
|
+
process.kill(-pid, signal);
|
|
118
|
+
} else {
|
|
119
|
+
proc.child.kill(signal);
|
|
120
|
+
}
|
|
121
|
+
} catch {
|
|
122
|
+
try {
|
|
123
|
+
proc.child.kill(signal);
|
|
124
|
+
} catch {
|
|
125
|
+
// already dead — nothing to do
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function sleep(ms: number): Promise<void> {
|
|
131
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Gracefully stop every managed process: SIGTERM the process groups, wait up
|
|
136
|
+
* to `timeoutMs` (default 5s), then SIGKILL any stragglers. Idempotent — safe
|
|
137
|
+
* to call twice (e.g. SIGINT and SIGTERM both arriving) and tolerant of
|
|
138
|
+
* already-exited children.
|
|
139
|
+
*/
|
|
140
|
+
export async function shutdownAll(
|
|
141
|
+
procs: ManagedProcess[],
|
|
142
|
+
opts?: { timeoutMs?: number },
|
|
143
|
+
): Promise<void> {
|
|
144
|
+
if (procs.length === 0) return;
|
|
145
|
+
const timeoutMs = opts?.timeoutMs ?? 5000;
|
|
146
|
+
|
|
147
|
+
for (const proc of procs) killTree(proc, "SIGTERM");
|
|
148
|
+
|
|
149
|
+
const allExited = Promise.all(procs.map((p) => p.exited));
|
|
150
|
+
await Promise.race([allExited, sleep(timeoutMs)]);
|
|
151
|
+
|
|
152
|
+
const stragglers = procs.filter((p) => !hasExited(p));
|
|
153
|
+
if (stragglers.length === 0) return;
|
|
154
|
+
|
|
155
|
+
for (const proc of stragglers) killTree(proc, "SIGKILL");
|
|
156
|
+
// Give the SIGKILLed children a beat to be reaped so callers don't return
|
|
157
|
+
// while the OS still lists them.
|
|
158
|
+
await Promise.race([allExited, sleep(2000)]);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Poll `url` every 500ms (2s per-attempt timeout) until it answers 2xx, or
|
|
163
|
+
* reject after `timeoutMs` with an actionable message carrying the URL and
|
|
164
|
+
* the last failure (connection error vs non-2xx status).
|
|
165
|
+
*/
|
|
166
|
+
export async function waitForHttp(
|
|
167
|
+
url: string,
|
|
168
|
+
timeoutMs: number,
|
|
169
|
+
): Promise<void> {
|
|
170
|
+
const deadline = Date.now() + timeoutMs;
|
|
171
|
+
let lastError = "no attempt completed";
|
|
172
|
+
|
|
173
|
+
do {
|
|
174
|
+
try {
|
|
175
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(2000) });
|
|
176
|
+
if (res.ok) return;
|
|
177
|
+
lastError = `last response: HTTP ${res.status}`;
|
|
178
|
+
} catch (err) {
|
|
179
|
+
lastError = `last error: ${
|
|
180
|
+
err instanceof Error ? err.message : String(err)
|
|
181
|
+
}`;
|
|
182
|
+
}
|
|
183
|
+
await sleep(500);
|
|
184
|
+
} while (Date.now() < deadline);
|
|
185
|
+
|
|
186
|
+
throw new Error(
|
|
187
|
+
`timed out after ${Math.round(timeoutMs / 1000)}s waiting for ${url} (${lastError})`,
|
|
188
|
+
);
|
|
189
|
+
}
|