@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.
Files changed (41) hide show
  1. package/dist/bin.js +13517 -1754
  2. package/dist/bin.js.map +1 -1
  3. package/package.json +6 -3
  4. package/skills/hogsend-authoring-emails/references/tracking-and-unsubscribe.md +5 -4
  5. package/skills/hogsend-cli/SKILL.md +32 -1
  6. package/skills/hogsend-extending/SKILL.md +4 -1
  7. package/skills/hogsend-extending/references/swap-a-provider.md +273 -51
  8. package/skills/hogsend-integrate/SKILL.md +198 -0
  9. package/skills/hogsend-integrate/references/auth-billing-seams.md +199 -0
  10. package/skills/hogsend-integrate/references/framework-recipes.md +208 -0
  11. package/skills/hogsend-integrate/references/verification.md +86 -0
  12. package/skills/hogsend-migrate/SKILL.md +147 -0
  13. package/skills/hogsend-migrate/references/customerio-mapping.md +93 -0
  14. package/skills/hogsend-migrate/references/cutover-checklist.md +136 -0
  15. package/skills/hogsend-migrate/references/loops-mapping.md +132 -0
  16. package/skills/hogsend-migrate/references/resend-broadcasts-mapping.md +120 -0
  17. package/src/__tests__/admin-recovery.test.ts +193 -0
  18. package/src/__tests__/dev.test.ts +323 -0
  19. package/src/__tests__/dns-apply.test.ts +297 -0
  20. package/src/__tests__/dns.test.ts +143 -0
  21. package/src/__tests__/domain-command.test.ts +216 -0
  22. package/src/__tests__/proc.test.ts +177 -0
  23. package/src/__tests__/setup-steps.test.ts +363 -0
  24. package/src/bin.ts +13 -3
  25. package/src/commands/dev.ts +444 -0
  26. package/src/commands/domain.ts +437 -0
  27. package/src/commands/events.ts +4 -1
  28. package/src/commands/index.ts +4 -0
  29. package/src/commands/setup.ts +34 -163
  30. package/src/commands/studio-admin.ts +340 -0
  31. package/src/commands/studio.ts +17 -1
  32. package/src/lib/admin-recovery.ts +193 -0
  33. package/src/lib/dns-apply.ts +218 -0
  34. package/src/lib/dns.ts +217 -0
  35. package/src/lib/proc.ts +189 -0
  36. package/src/lib/setup-steps.ts +333 -0
  37. package/studio/assets/index-CSXAjTbe.js +265 -0
  38. package/studio/assets/index-DCsT0fnT.css +1 -0
  39. package/studio/index.html +2 -2
  40. package/studio/assets/index-BNDE5JtQ.css +0 -1
  41. package/studio/assets/index-CgJBk-Ft.js +0 -250
@@ -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
+ };
@@ -167,7 +167,10 @@ async function runRead(ctx: CommandContext, argv: string[]): Promise<void> {
167
167
  );
168
168
  }
169
169
 
170
- async function runSend(ctx: CommandContext, argv: string[]): Promise<void> {
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,
@@ -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,
@@ -1,11 +1,18 @@
1
- import { spawnSync } from "node:child_process";
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 docker = await ctx.out.step(
218
- "Starting infra (docker compose up -d)",
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
- results.push({
222
- step: "docker",
223
- status: docker.ok ? "ok" : "failed",
224
- detail: docker.ok
225
- ? "Postgres + Redis + Hatchet-Lite up"
226
- : `docker compose exited with code ${docker.status ?? "?"}`,
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
- ensureEnv(cwd),
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 () => runCmd("pnpm", ["db:migrate"], cwd, ctx.json),
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");