@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.
- 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,444 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import { parseArgs } from "node:util";
|
|
5
|
+
import { color } from "../lib/output.js";
|
|
6
|
+
import {
|
|
7
|
+
type ManagedProcess,
|
|
8
|
+
shutdownAll,
|
|
9
|
+
spawnManaged,
|
|
10
|
+
waitForHttp,
|
|
11
|
+
} from "../lib/proc.js";
|
|
12
|
+
import {
|
|
13
|
+
detectRunningInfra,
|
|
14
|
+
dockerComposeUp,
|
|
15
|
+
ensureAuthSecret,
|
|
16
|
+
ensureEnvFile,
|
|
17
|
+
hasComposeFile,
|
|
18
|
+
probeTcp,
|
|
19
|
+
readDotEnv,
|
|
20
|
+
runMigrations,
|
|
21
|
+
} from "../lib/setup-steps.js";
|
|
22
|
+
import { runSend } from "./events.js";
|
|
23
|
+
import type { Command, CommandContext } from "./types.js";
|
|
24
|
+
|
|
25
|
+
const usage = `hogsend dev [options]
|
|
26
|
+
hogsend dev --fire <event> [event-send options]
|
|
27
|
+
|
|
28
|
+
Run the full local stack for a Hogsend app from one command:
|
|
29
|
+
|
|
30
|
+
1. infra detect running containers, docker compose up -d when needed
|
|
31
|
+
2. .env cp .env.example -> .env + generate BETTER_AUTH_SECRET
|
|
32
|
+
3. migrate pnpm db:migrate (when the app has the script)
|
|
33
|
+
4. spawn [api] pnpm run dev + [worker] hatchet worker dev
|
|
34
|
+
(falls back to pnpm run worker:dev without hatchet CLI/config)
|
|
35
|
+
5. health wait for GET /v1/health, then print the local URLs
|
|
36
|
+
|
|
37
|
+
Ctrl+C stops everything — the API, the worker, and their whole process trees.
|
|
38
|
+
|
|
39
|
+
Options:
|
|
40
|
+
--cwd <dir> Project root to run in (defaults to the current directory).
|
|
41
|
+
--no-worker Start the API only (skip the worker process).
|
|
42
|
+
--no-infra Skip the docker/.env/migrate steps (infra managed elsewhere).
|
|
43
|
+
--fire <event> Don't boot anything — send a test event to the RUNNING
|
|
44
|
+
instance via POST /v1/events (works from a second terminal
|
|
45
|
+
while hogsend dev runs in the first). Accepts every
|
|
46
|
+
\`hogsend events send\` option: --email, --user-id, --prop,
|
|
47
|
+
--props, --contact-prop, --contact-props, --list, --unlist,
|
|
48
|
+
--idempotency-key, --timestamp.
|
|
49
|
+
-h, --help Show this help.
|
|
50
|
+
|
|
51
|
+
Examples:
|
|
52
|
+
hogsend dev
|
|
53
|
+
hogsend dev --cwd apps/api
|
|
54
|
+
hogsend dev --no-worker
|
|
55
|
+
hogsend dev --fire signup --email a@b.com --prop plan=pro`;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Structural type for GET /v1/admin/domain — deliberately NOT imported from
|
|
59
|
+
* the engine so \`hogsend dev\` works against engines without the domain
|
|
60
|
+
* feature (the call is guarded; on 404/501/error the line is just omitted).
|
|
61
|
+
*/
|
|
62
|
+
export interface DomainStatusLike {
|
|
63
|
+
domain: string | null;
|
|
64
|
+
status: { state: string } | null;
|
|
65
|
+
testMode: { active: boolean; redirectTo: string | null } | null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Render the one-line domain/test-mode status, or null when there's nothing to say. */
|
|
69
|
+
export function renderDomainLine(d: DomainStatusLike): string | null {
|
|
70
|
+
if (d.testMode?.active) {
|
|
71
|
+
const target = d.testMode.redirectTo ?? "(no redirect address)";
|
|
72
|
+
const state = d.status?.state;
|
|
73
|
+
const suffix =
|
|
74
|
+
d.domain && state && state !== "verified" ? ` (domain ${state})` : "";
|
|
75
|
+
return color.yellow(
|
|
76
|
+
`Test mode active — emails redirect to ${target}${suffix}`,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
if (d.domain && d.status) {
|
|
80
|
+
const state =
|
|
81
|
+
d.status.state === "verified"
|
|
82
|
+
? color.green("verified")
|
|
83
|
+
: color.yellow(d.status.state);
|
|
84
|
+
return `${color.dim("Domain")} ${d.domain} — ${state}`;
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Guarded soft-consume of GET /v1/admin/domain. Returns null (omitting the
|
|
91
|
+
* line entirely) when no admin key is configured, when the route 404s/501s
|
|
92
|
+
* (engine without the domain feature), or on any network/shape error. Never
|
|
93
|
+
* throws, never delays startup beyond one quick request.
|
|
94
|
+
*/
|
|
95
|
+
export async function fetchDomainLine(
|
|
96
|
+
ctx: CommandContext,
|
|
97
|
+
): Promise<string | null> {
|
|
98
|
+
if (!ctx.cfg.adminKey) return null;
|
|
99
|
+
try {
|
|
100
|
+
const d = await ctx.http.get<DomainStatusLike>("/v1/admin/domain");
|
|
101
|
+
if (d === null || typeof d !== "object") return null;
|
|
102
|
+
return renderDomainLine(d);
|
|
103
|
+
} catch {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Detect `--fire <event>` / `--fire=<event>` anywhere in argv, returning the
|
|
110
|
+
* event plus the remaining args (handed verbatim to the events send parser).
|
|
111
|
+
*/
|
|
112
|
+
function extractFire(
|
|
113
|
+
argv: string[],
|
|
114
|
+
): { event: string; rest: string[] } | { error: string } | null {
|
|
115
|
+
for (let i = 0; i < argv.length; i++) {
|
|
116
|
+
const token = argv[i] as string;
|
|
117
|
+
if (token === "--fire") {
|
|
118
|
+
const next = argv[i + 1];
|
|
119
|
+
if (next === undefined || next.startsWith("-")) {
|
|
120
|
+
return {
|
|
121
|
+
error:
|
|
122
|
+
"--fire requires an event name, e.g. hogsend dev --fire signup --email a@b.com",
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
return {
|
|
126
|
+
event: next,
|
|
127
|
+
rest: [...argv.slice(0, i), ...argv.slice(i + 2)],
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
if (token.startsWith("--fire=")) {
|
|
131
|
+
const event = token.slice("--fire=".length);
|
|
132
|
+
if (event === "") {
|
|
133
|
+
return { error: "--fire requires an event name" };
|
|
134
|
+
}
|
|
135
|
+
return { event, rest: [...argv.slice(0, i), ...argv.slice(i + 1)] };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** The --fire path: quick reachability check, then delegate to events send. */
|
|
142
|
+
async function runFire(
|
|
143
|
+
ctx: CommandContext,
|
|
144
|
+
event: string,
|
|
145
|
+
rest: string[],
|
|
146
|
+
): Promise<void> {
|
|
147
|
+
if (rest.includes("-h") || rest.includes("--help")) {
|
|
148
|
+
ctx.out.log(usage);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
const res = await fetch(`${ctx.cfg.baseUrl}/v1/health`, {
|
|
154
|
+
signal: AbortSignal.timeout(2000),
|
|
155
|
+
});
|
|
156
|
+
if (!res.ok) throw new Error(`health returned HTTP ${res.status}`);
|
|
157
|
+
} catch (err) {
|
|
158
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
159
|
+
ctx.out.fail(
|
|
160
|
+
`cannot reach ${ctx.cfg.baseUrl} — is hogsend dev running? (${msg})`,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
await runSend(ctx, [event, ...rest]);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
interface PackageJsonLike {
|
|
168
|
+
scripts?: Record<string, string>;
|
|
169
|
+
dependencies?: Record<string, string>;
|
|
170
|
+
devDependencies?: Record<string, string>;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Verify `cwd` is a runnable Hogsend app: package.json with `dev` +
|
|
175
|
+
* `worker:dev` scripts and `@hogsend/engine` as a dependency (covers both
|
|
176
|
+
* create-hogsend scaffolds and the dogfood apps/api). Fails with the missing
|
|
177
|
+
* piece named.
|
|
178
|
+
*/
|
|
179
|
+
function assertHogsendApp(cwd: string, ctx: CommandContext): PackageJsonLike {
|
|
180
|
+
const pkgPath = join(cwd, "package.json");
|
|
181
|
+
if (!existsSync(pkgPath)) {
|
|
182
|
+
ctx.out.fail(
|
|
183
|
+
`not a Hogsend app — no package.json in ${cwd}. Run inside a scaffolded ` +
|
|
184
|
+
"app (pnpm dlx create-hogsend@latest) or pass --cwd <dir>.",
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
let pkg: PackageJsonLike;
|
|
189
|
+
try {
|
|
190
|
+
pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as PackageJsonLike;
|
|
191
|
+
} catch (err) {
|
|
192
|
+
ctx.out.fail(
|
|
193
|
+
`could not parse ${pkgPath}: ${err instanceof Error ? err.message : String(err)}`,
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const scripts = pkg.scripts ?? {};
|
|
198
|
+
for (const script of ["dev", "worker:dev"]) {
|
|
199
|
+
if (!scripts[script]) {
|
|
200
|
+
ctx.out.fail(
|
|
201
|
+
`not a runnable Hogsend app — package.json in ${cwd} has no "${script}" ` +
|
|
202
|
+
"script. Scaffold one with pnpm dlx create-hogsend@latest, or pass " +
|
|
203
|
+
"--cwd <dir>.",
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
209
|
+
if (!deps["@hogsend/engine"]) {
|
|
210
|
+
ctx.out.fail(
|
|
211
|
+
`not a Hogsend app — @hogsend/engine is not a dependency in ${pkgPath}. ` +
|
|
212
|
+
"Scaffold one with pnpm dlx create-hogsend@latest, or pass --cwd <dir>.",
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return pkg;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** True when the hatchet CLI binary is available on PATH. */
|
|
220
|
+
function hatchetOnPath(): boolean {
|
|
221
|
+
try {
|
|
222
|
+
const result = spawnSync("hatchet", ["--version"], { stdio: "ignore" });
|
|
223
|
+
return !result.error && result.status === 0;
|
|
224
|
+
} catch {
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function parsePort(raw: string | undefined, fallback: number): number {
|
|
230
|
+
if (raw === undefined) return fallback;
|
|
231
|
+
const n = Number(raw);
|
|
232
|
+
return Number.isInteger(n) && n > 0 && n < 65536 ? n : fallback;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Infra phase: compose (skipped when already running), .env, migrations. */
|
|
236
|
+
async function prepareInfra(
|
|
237
|
+
ctx: CommandContext,
|
|
238
|
+
cwd: string,
|
|
239
|
+
pkg: PackageJsonLike,
|
|
240
|
+
): Promise<void> {
|
|
241
|
+
if (!hasComposeFile(cwd)) {
|
|
242
|
+
ctx.out.log(
|
|
243
|
+
color.dim(
|
|
244
|
+
" no docker-compose file — skipping docker (infra managed elsewhere)",
|
|
245
|
+
),
|
|
246
|
+
);
|
|
247
|
+
} else {
|
|
248
|
+
const infra = await ctx.out.step("Checking infra", () =>
|
|
249
|
+
detectRunningInfra(cwd),
|
|
250
|
+
);
|
|
251
|
+
if (infra.postgres && infra.redis && infra.hatchet) {
|
|
252
|
+
ctx.out.log(
|
|
253
|
+
color.dim(" infra already running — skipping docker compose up"),
|
|
254
|
+
);
|
|
255
|
+
} else {
|
|
256
|
+
const docker = await ctx.out.step(
|
|
257
|
+
"Starting infra (docker compose up -d)",
|
|
258
|
+
() => dockerComposeUp(cwd, { quiet: ctx.json }),
|
|
259
|
+
);
|
|
260
|
+
if (docker.status === "failed") {
|
|
261
|
+
ctx.out.fail(
|
|
262
|
+
`${docker.detail}. Is Docker running? Start Docker Desktop (or your ` +
|
|
263
|
+
"docker daemon) and re-run hogsend dev — or pass --no-infra when " +
|
|
264
|
+
"infra is managed elsewhere.",
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const envFile = ensureEnvFile(cwd);
|
|
271
|
+
if (envFile.status === "failed") {
|
|
272
|
+
ctx.out.fail(
|
|
273
|
+
`${envFile.detail} — create a .env (or .env.example) in ${cwd} first.`,
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
const secret = ensureAuthSecret(cwd);
|
|
277
|
+
ctx.out.log(color.dim(` env: ${envFile.detail} · ${secret.detail}`));
|
|
278
|
+
|
|
279
|
+
if (pkg.scripts?.["db:migrate"]) {
|
|
280
|
+
const migrate = await ctx.out.step(
|
|
281
|
+
"Running migrations (pnpm db:migrate)",
|
|
282
|
+
() => runMigrations(cwd, { quiet: ctx.json }),
|
|
283
|
+
);
|
|
284
|
+
if (migrate.status === "failed") {
|
|
285
|
+
ctx.out.fail(`${migrate.detail} — fix, then re-run hogsend dev.`);
|
|
286
|
+
}
|
|
287
|
+
} else {
|
|
288
|
+
ctx.out.log(color.dim(" no db:migrate script — skipping migrations"));
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async function run(ctx: CommandContext): Promise<void> {
|
|
293
|
+
// --fire mode never boots the stack; detect it before strict flag parsing
|
|
294
|
+
// so the event-send flags (--email, --prop, ...) pass through verbatim.
|
|
295
|
+
const fire = extractFire(ctx.argv);
|
|
296
|
+
if (fire && "error" in fire) {
|
|
297
|
+
ctx.out.fail(fire.error);
|
|
298
|
+
}
|
|
299
|
+
if (fire) {
|
|
300
|
+
await runFire(ctx, fire.event, fire.rest);
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const { values } = parseArgs({
|
|
305
|
+
args: ctx.argv,
|
|
306
|
+
allowPositionals: true,
|
|
307
|
+
options: {
|
|
308
|
+
cwd: { type: "string" },
|
|
309
|
+
"no-worker": { type: "boolean", default: false },
|
|
310
|
+
"no-infra": { type: "boolean", default: false },
|
|
311
|
+
help: { type: "boolean", short: "h", default: false },
|
|
312
|
+
},
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
if (values.help) {
|
|
316
|
+
ctx.out.log(usage);
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const cwd = resolve(values.cwd ?? process.cwd());
|
|
321
|
+
const pkg = assertHogsendApp(cwd, ctx);
|
|
322
|
+
|
|
323
|
+
ctx.out.intro(
|
|
324
|
+
`${color.bgMagenta(color.black(" hogsend "))} ${color.dim("dev")}`,
|
|
325
|
+
);
|
|
326
|
+
|
|
327
|
+
if (!values["no-infra"]) {
|
|
328
|
+
await prepareInfra(ctx, cwd, pkg);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Host ports come from the app's .env — pnpm bootstrap may have remapped
|
|
332
|
+
// busy ports, so hardcoded defaults would print wrong URLs there.
|
|
333
|
+
const dotenv = readDotEnv(cwd);
|
|
334
|
+
const port = parsePort(dotenv.PORT, 3002);
|
|
335
|
+
const hatchetPort = parsePort(dotenv.HATCHET_DASHBOARD_PORT, 8888);
|
|
336
|
+
const apiBase = `http://localhost:${port}`;
|
|
337
|
+
|
|
338
|
+
if (await probeTcp({ port })) {
|
|
339
|
+
ctx.out.fail(
|
|
340
|
+
`port ${port} is already in use — is another dev server (or hogsend dev) ` +
|
|
341
|
+
`already running? Stop it, or change PORT in ${join(cwd, ".env")}.`,
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const procs: ManagedProcess[] = [];
|
|
346
|
+
let shuttingDown = false;
|
|
347
|
+
const shutdown = async (code: number): Promise<never> => {
|
|
348
|
+
shuttingDown = true;
|
|
349
|
+
await shutdownAll(procs);
|
|
350
|
+
process.exit(code);
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
process.once("SIGINT", () => {
|
|
354
|
+
if (shuttingDown) return;
|
|
355
|
+
ctx.out.log(`\n${color.dim("Shutting down…")}`);
|
|
356
|
+
void shutdown(0);
|
|
357
|
+
});
|
|
358
|
+
process.once("SIGTERM", () => {
|
|
359
|
+
if (shuttingDown) return;
|
|
360
|
+
void shutdown(0);
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
procs.push(
|
|
364
|
+
spawnManaged({
|
|
365
|
+
name: "api",
|
|
366
|
+
cmd: "pnpm",
|
|
367
|
+
args: ["run", "dev"],
|
|
368
|
+
cwd,
|
|
369
|
+
prefixColor: color.cyan,
|
|
370
|
+
}),
|
|
371
|
+
);
|
|
372
|
+
|
|
373
|
+
if (!values["no-worker"]) {
|
|
374
|
+
// The hatchet CLI dev mode needs both the binary AND a hatchet.yaml in the
|
|
375
|
+
// app (the scaffold template ships none) — otherwise plain worker:dev.
|
|
376
|
+
const useHatchetCli =
|
|
377
|
+
existsSync(join(cwd, "hatchet.yaml")) && hatchetOnPath();
|
|
378
|
+
const mode = useHatchetCli ? "hatchet worker dev" : "pnpm run worker:dev";
|
|
379
|
+
ctx.out.log(color.dim(` worker mode: ${mode}`));
|
|
380
|
+
procs.push(
|
|
381
|
+
spawnManaged({
|
|
382
|
+
name: "worker",
|
|
383
|
+
cmd: useHatchetCli ? "hatchet" : "pnpm",
|
|
384
|
+
args: useHatchetCli ? ["worker", "dev"] : ["run", "worker:dev"],
|
|
385
|
+
cwd,
|
|
386
|
+
prefixColor: color.magenta,
|
|
387
|
+
}),
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// If any child dies on its own, take the rest down and exit with its code.
|
|
392
|
+
for (const proc of procs) {
|
|
393
|
+
proc.onExit(({ code }) => {
|
|
394
|
+
if (shuttingDown) return;
|
|
395
|
+
shuttingDown = true;
|
|
396
|
+
ctx.out.log(
|
|
397
|
+
color.red(
|
|
398
|
+
`\n[${proc.name}] exited with code ${code ?? "?"} — shutting down.`,
|
|
399
|
+
),
|
|
400
|
+
);
|
|
401
|
+
void shutdownAll(procs).then(() => process.exit(code ?? 1));
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
try {
|
|
406
|
+
await ctx.out.step("Waiting for API health", () =>
|
|
407
|
+
waitForHttp(`${apiBase}/v1/health`, 60_000),
|
|
408
|
+
);
|
|
409
|
+
} catch (err) {
|
|
410
|
+
if (shuttingDown) return;
|
|
411
|
+
shuttingDown = true;
|
|
412
|
+
await shutdownAll(procs);
|
|
413
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
414
|
+
ctx.out.fail(
|
|
415
|
+
`API did not become healthy: ${msg}. Check the [api] log lines above.`,
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const domainLine = await fetchDomainLine(ctx);
|
|
420
|
+
|
|
421
|
+
const lines = [
|
|
422
|
+
`${color.green("●")} API ${color.cyan(apiBase)}`,
|
|
423
|
+
`${color.green("●")} Studio ${color.cyan(`${apiBase}/studio`)}`,
|
|
424
|
+
`${color.green("●")} Hatchet ${color.cyan(`http://localhost:${hatchetPort}`)}`,
|
|
425
|
+
`${color.green("●")} Docs ${color.cyan("https://docs.hogsend.com")}`,
|
|
426
|
+
];
|
|
427
|
+
if (domainLine) lines.push("", domainLine);
|
|
428
|
+
lines.push(
|
|
429
|
+
"",
|
|
430
|
+
`${color.dim("Fire a test event:")} hogsend dev --fire signup --email you@example.com`,
|
|
431
|
+
color.dim("Press Ctrl+C to stop everything."),
|
|
432
|
+
);
|
|
433
|
+
ctx.out.note(lines.join("\n"), "hogsend dev");
|
|
434
|
+
|
|
435
|
+
// Keep the process alive until a signal or a child exit triggers shutdown.
|
|
436
|
+
await new Promise<void>(() => {});
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
export const devCommand: Command = {
|
|
440
|
+
name: "dev",
|
|
441
|
+
summary: "Run the full local stack: infra, API + worker, health, URLs",
|
|
442
|
+
usage,
|
|
443
|
+
run,
|
|
444
|
+
};
|