@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,333 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { copyFileSync, existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { connect } from "node:net";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { loadDotEnv } from "./config.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The shared local-onboarding steps, extracted from `commands/setup.ts` so
|
|
10
|
+
* both `hogsend setup` (which keeps its exact CLI shell) and `hogsend dev`
|
|
11
|
+
* can reuse them. The exported function signatures are pinned
|
|
12
|
+
* (PROJECT_SPEC §d) — only additive optional params are allowed.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export interface StepResult {
|
|
16
|
+
step: string;
|
|
17
|
+
status: "ok" | "skipped" | "failed";
|
|
18
|
+
detail: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const SECRET_KEY = "BETTER_AUTH_SECRET";
|
|
22
|
+
export const PLACEHOLDER_PREFIX = "change-me";
|
|
23
|
+
/**
|
|
24
|
+
* Placeholder prefixes that mark a scaffold/example value, never a real secret:
|
|
25
|
+
* `change-me…` (create-hogsend template) and `REPLACE_ME…` (the dogfood
|
|
26
|
+
* apps/api `.env.example`, e.g. `REPLACE_ME_RUN_pnpm_gen:secret`).
|
|
27
|
+
*/
|
|
28
|
+
const PLACEHOLDER_PREFIXES = [PLACEHOLDER_PREFIX, "REPLACE_ME"] as const;
|
|
29
|
+
|
|
30
|
+
/** Generate a 64-char hex secret (32 bytes) for BETTER_AUTH_SECRET. */
|
|
31
|
+
export function generateSecret(): string {
|
|
32
|
+
return randomBytes(32).toString("hex");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const COMPOSE_FILES = [
|
|
36
|
+
"docker-compose.yml",
|
|
37
|
+
"docker-compose.yaml",
|
|
38
|
+
"compose.yml",
|
|
39
|
+
"compose.yaml",
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
/** True when any docker-compose file variant exists in `cwd`. */
|
|
43
|
+
export function hasComposeFile(cwd: string): boolean {
|
|
44
|
+
return COMPOSE_FILES.some((name) => existsSync(join(cwd, name)));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Read the *app's* `.env` (KEY=value lines, `export ` prefix and comments
|
|
49
|
+
* tolerated, never throws). Used for host-port resolution — `pnpm bootstrap`
|
|
50
|
+
* may have remapped busy ports into `.env`, so these values are
|
|
51
|
+
* authoritative over the defaults. Distinct from CLI target-config
|
|
52
|
+
* resolution (`lib/config.ts` `resolveConfig`), which this must not entangle.
|
|
53
|
+
*/
|
|
54
|
+
export function readDotEnv(cwd: string): Record<string, string> {
|
|
55
|
+
return loadDotEnv(cwd);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Copy `.env.example` → `.env` when `.env` is missing. */
|
|
59
|
+
export function ensureEnvFile(cwd: string): StepResult {
|
|
60
|
+
const envPath = join(cwd, ".env");
|
|
61
|
+
const examplePath = join(cwd, ".env.example");
|
|
62
|
+
|
|
63
|
+
if (existsSync(envPath)) {
|
|
64
|
+
return { step: "env", status: "skipped", detail: ".env already exists" };
|
|
65
|
+
}
|
|
66
|
+
if (existsSync(examplePath)) {
|
|
67
|
+
copyFileSync(examplePath, envPath);
|
|
68
|
+
return {
|
|
69
|
+
step: "env",
|
|
70
|
+
status: "ok",
|
|
71
|
+
detail: "copied .env.example -> .env",
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
step: "env",
|
|
76
|
+
status: "failed",
|
|
77
|
+
detail: "no .env and no .env.example to copy from",
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Ensure BETTER_AUTH_SECRET in `.env` holds a real generated value: replaces
|
|
83
|
+
* the scaffold placeholder / a missing key with 64-char hex. NEVER overwrites
|
|
84
|
+
* a real secret. Skipped when no `.env` exists.
|
|
85
|
+
*/
|
|
86
|
+
export function ensureAuthSecret(cwd: string): StepResult {
|
|
87
|
+
const envPath = join(cwd, ".env");
|
|
88
|
+
if (!existsSync(envPath)) {
|
|
89
|
+
return { step: "secret", status: "skipped", detail: "skipped — no .env" };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
let raw: string;
|
|
93
|
+
try {
|
|
94
|
+
raw = readFileSync(envPath, "utf8");
|
|
95
|
+
} catch (err) {
|
|
96
|
+
return {
|
|
97
|
+
step: "secret",
|
|
98
|
+
status: "failed",
|
|
99
|
+
detail: `could not read .env: ${err instanceof Error ? err.message : String(err)}`,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const lines = raw.split(/\r?\n/);
|
|
104
|
+
const idx = lines.findIndex((l) =>
|
|
105
|
+
l
|
|
106
|
+
.replace(/^export\s+/, "")
|
|
107
|
+
.trimStart()
|
|
108
|
+
.startsWith(`${SECRET_KEY}=`),
|
|
109
|
+
);
|
|
110
|
+
const existingLine = idx === -1 ? undefined : lines[idx];
|
|
111
|
+
const current =
|
|
112
|
+
existingLine === undefined
|
|
113
|
+
? undefined
|
|
114
|
+
: existingLine.slice(existingLine.indexOf("=") + 1).trim();
|
|
115
|
+
const isPlaceholder =
|
|
116
|
+
current === undefined ||
|
|
117
|
+
current === "" ||
|
|
118
|
+
PLACEHOLDER_PREFIXES.some((prefix) => current.startsWith(prefix));
|
|
119
|
+
|
|
120
|
+
if (!isPlaceholder) {
|
|
121
|
+
return {
|
|
122
|
+
step: "secret",
|
|
123
|
+
status: "skipped",
|
|
124
|
+
detail: `${SECRET_KEY} already set`,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const secret = generateSecret();
|
|
129
|
+
const newLine = `${SECRET_KEY}=${secret}`;
|
|
130
|
+
if (idx === -1) {
|
|
131
|
+
if (raw.length > 0 && !raw.endsWith("\n")) lines.push("");
|
|
132
|
+
lines.push(newLine);
|
|
133
|
+
} else {
|
|
134
|
+
lines[idx] = newLine;
|
|
135
|
+
}
|
|
136
|
+
writeFileSync(envPath, lines.join("\n"));
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
step: "secret",
|
|
140
|
+
status: "ok",
|
|
141
|
+
detail: `generated ${SECRET_KEY} (64-char hex)`,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Options shared by the spawning steps. */
|
|
146
|
+
export interface RunStepOptions {
|
|
147
|
+
/** Silence child stdio (used by `--json` runs); default streams inline. */
|
|
148
|
+
quiet?: boolean;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Run a shell command, capturing exit status. */
|
|
152
|
+
function runCmd(
|
|
153
|
+
cmd: string,
|
|
154
|
+
args: string[],
|
|
155
|
+
cwd: string,
|
|
156
|
+
quiet: boolean,
|
|
157
|
+
): { status: number | null; ok: boolean } {
|
|
158
|
+
const result = spawnSync(cmd, args, {
|
|
159
|
+
cwd,
|
|
160
|
+
stdio: quiet ? "ignore" : "inherit",
|
|
161
|
+
});
|
|
162
|
+
return { status: result.status, ok: result.status === 0 };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** `docker compose up -d` in `cwd`, wrapped as a StepResult. */
|
|
166
|
+
export async function dockerComposeUp(
|
|
167
|
+
cwd: string,
|
|
168
|
+
opts?: RunStepOptions,
|
|
169
|
+
): Promise<StepResult> {
|
|
170
|
+
const result = runCmd(
|
|
171
|
+
"docker",
|
|
172
|
+
["compose", "up", "-d"],
|
|
173
|
+
cwd,
|
|
174
|
+
opts?.quiet ?? false,
|
|
175
|
+
);
|
|
176
|
+
return {
|
|
177
|
+
step: "docker",
|
|
178
|
+
status: result.ok ? "ok" : "failed",
|
|
179
|
+
detail: result.ok
|
|
180
|
+
? "Postgres + Redis + Hatchet-Lite up"
|
|
181
|
+
: `docker compose exited with code ${result.status ?? "?"}`,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** `pnpm db:migrate` in `cwd`, wrapped as a StepResult. */
|
|
186
|
+
export async function runMigrations(
|
|
187
|
+
cwd: string,
|
|
188
|
+
opts?: RunStepOptions,
|
|
189
|
+
): Promise<StepResult> {
|
|
190
|
+
const result = runCmd("pnpm", ["db:migrate"], cwd, opts?.quiet ?? false);
|
|
191
|
+
return {
|
|
192
|
+
step: "migrate",
|
|
193
|
+
status: result.ok ? "ok" : "failed",
|
|
194
|
+
detail: result.ok
|
|
195
|
+
? "engine + client migrations applied"
|
|
196
|
+
: `pnpm db:migrate exited with code ${result.status ?? "?"}`,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** TCP connect probe — true when something is listening. Never throws. */
|
|
201
|
+
export function probeTcp(opts: {
|
|
202
|
+
port: number;
|
|
203
|
+
host?: string;
|
|
204
|
+
timeoutMs?: number;
|
|
205
|
+
}): Promise<boolean> {
|
|
206
|
+
const { port, host = "127.0.0.1", timeoutMs = 750 } = opts;
|
|
207
|
+
return new Promise((resolve) => {
|
|
208
|
+
let settled = false;
|
|
209
|
+
const socket = connect({ port, host });
|
|
210
|
+
const done = (ok: boolean) => {
|
|
211
|
+
if (settled) return;
|
|
212
|
+
settled = true;
|
|
213
|
+
socket.destroy();
|
|
214
|
+
resolve(ok);
|
|
215
|
+
};
|
|
216
|
+
socket.once("connect", () => done(true));
|
|
217
|
+
socket.once("error", () => done(false));
|
|
218
|
+
socket.setTimeout(timeoutMs, () => done(false));
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function envPort(
|
|
223
|
+
env: Record<string, string>,
|
|
224
|
+
key: string,
|
|
225
|
+
fallback: number,
|
|
226
|
+
): number {
|
|
227
|
+
const raw = env[key];
|
|
228
|
+
if (raw === undefined) return fallback;
|
|
229
|
+
const n = Number(raw);
|
|
230
|
+
return Number.isInteger(n) && n > 0 && n < 65536 ? n : fallback;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
interface ComposePsEntry {
|
|
234
|
+
service: string;
|
|
235
|
+
state: string;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Parse `docker compose ps --format json` output (line-json or an array). */
|
|
239
|
+
function parseComposePs(stdout: string): ComposePsEntry[] {
|
|
240
|
+
const entries: ComposePsEntry[] = [];
|
|
241
|
+
const push = (value: unknown) => {
|
|
242
|
+
if (value === null || typeof value !== "object") return;
|
|
243
|
+
const obj = value as Record<string, unknown>;
|
|
244
|
+
const service = obj.Service ?? obj.Name;
|
|
245
|
+
const state = obj.State;
|
|
246
|
+
if (typeof service === "string" && typeof state === "string") {
|
|
247
|
+
entries.push({ service, state: state.toLowerCase() });
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
const trimmed = stdout.trim();
|
|
252
|
+
if (trimmed === "") return entries;
|
|
253
|
+
if (trimmed.startsWith("[")) {
|
|
254
|
+
try {
|
|
255
|
+
const arr = JSON.parse(trimmed);
|
|
256
|
+
if (Array.isArray(arr)) for (const item of arr) push(item);
|
|
257
|
+
} catch {
|
|
258
|
+
// unparseable — treated as no entries
|
|
259
|
+
}
|
|
260
|
+
return entries;
|
|
261
|
+
}
|
|
262
|
+
for (const line of trimmed.split(/\r?\n/)) {
|
|
263
|
+
try {
|
|
264
|
+
push(JSON.parse(line));
|
|
265
|
+
} catch {
|
|
266
|
+
// skip non-json lines (compose warnings etc.)
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return entries;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Detect whether the local infra trio (Postgres, Redis, Hatchet-Lite) is
|
|
274
|
+
* already running. Strategy:
|
|
275
|
+
*
|
|
276
|
+
* 1. `docker compose ps --format json` in `cwd` — service names mapped to
|
|
277
|
+
* `running` state;
|
|
278
|
+
* 2. for anything still unknown (docker CLI missing, no compose project in
|
|
279
|
+
* `cwd`, or containers started elsewhere — e.g. another checkout), fall
|
|
280
|
+
* back to TCP probes against the host ports read from `cwd/.env`
|
|
281
|
+
* (`POSTGRES_PORT` 5434, `REDIS_PORT` 6380, `HATCHET_DASHBOARD_PORT`
|
|
282
|
+
* 8888 — `pnpm bootstrap` may have remapped these).
|
|
283
|
+
*
|
|
284
|
+
* Never throws.
|
|
285
|
+
*/
|
|
286
|
+
export async function detectRunningInfra(
|
|
287
|
+
cwd: string,
|
|
288
|
+
): Promise<{ postgres: boolean; redis: boolean; hatchet: boolean }> {
|
|
289
|
+
const found = { postgres: false, redis: false, hatchet: false };
|
|
290
|
+
|
|
291
|
+
try {
|
|
292
|
+
const result = spawnSync("docker", ["compose", "ps", "--format", "json"], {
|
|
293
|
+
cwd,
|
|
294
|
+
encoding: "utf8",
|
|
295
|
+
});
|
|
296
|
+
if (
|
|
297
|
+
!result.error &&
|
|
298
|
+
result.status === 0 &&
|
|
299
|
+
typeof result.stdout === "string"
|
|
300
|
+
) {
|
|
301
|
+
for (const entry of parseComposePs(result.stdout)) {
|
|
302
|
+
if (entry.state !== "running") continue;
|
|
303
|
+
if (entry.service === "postgres") {
|
|
304
|
+
found.postgres = true;
|
|
305
|
+
} else if (entry.service === "redis") {
|
|
306
|
+
found.redis = true;
|
|
307
|
+
} else if (
|
|
308
|
+
entry.service.startsWith("hatchet") &&
|
|
309
|
+
!entry.service.includes("postgres")
|
|
310
|
+
) {
|
|
311
|
+
found.hatchet = true;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
} catch {
|
|
316
|
+
// fall through to port probes
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (found.postgres && found.redis && found.hatchet) return found;
|
|
320
|
+
|
|
321
|
+
try {
|
|
322
|
+
const env = readDotEnv(cwd);
|
|
323
|
+
const [postgres, redis, hatchet] = await Promise.all([
|
|
324
|
+
found.postgres || probeTcp({ port: envPort(env, "POSTGRES_PORT", 5434) }),
|
|
325
|
+
found.redis || probeTcp({ port: envPort(env, "REDIS_PORT", 6380) }),
|
|
326
|
+
found.hatchet ||
|
|
327
|
+
probeTcp({ port: envPort(env, "HATCHET_DASHBOARD_PORT", 8888) }),
|
|
328
|
+
]);
|
|
329
|
+
return { postgres, redis, hatchet };
|
|
330
|
+
} catch {
|
|
331
|
+
return found;
|
|
332
|
+
}
|
|
333
|
+
}
|