@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,177 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import type { AddressInfo } from "node:net";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import {
|
|
5
|
+
type ManagedProcess,
|
|
6
|
+
shutdownAll,
|
|
7
|
+
spawnManaged,
|
|
8
|
+
waitForHttp,
|
|
9
|
+
} from "../lib/proc.js";
|
|
10
|
+
|
|
11
|
+
const node = process.execPath;
|
|
12
|
+
const identity = (s: string) => s;
|
|
13
|
+
|
|
14
|
+
/** Spawn a managed node -e child, capturing prefixed lines via the sink. */
|
|
15
|
+
function spawnFixture(opts: {
|
|
16
|
+
name: string;
|
|
17
|
+
script: string;
|
|
18
|
+
prefixColor?: (s: string) => string;
|
|
19
|
+
env?: Record<string, string>;
|
|
20
|
+
}): { proc: ManagedProcess; lines: string[] } {
|
|
21
|
+
const lines: string[] = [];
|
|
22
|
+
const proc = spawnManaged({
|
|
23
|
+
name: opts.name,
|
|
24
|
+
cmd: node,
|
|
25
|
+
args: ["-e", opts.script],
|
|
26
|
+
cwd: process.cwd(),
|
|
27
|
+
env: opts.env,
|
|
28
|
+
prefixColor: opts.prefixColor ?? identity,
|
|
29
|
+
sink: (s) => lines.push(s),
|
|
30
|
+
});
|
|
31
|
+
return { proc, lines };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Wait until a predicate over captured lines holds (or time out). */
|
|
35
|
+
async function waitForLines(
|
|
36
|
+
lines: string[],
|
|
37
|
+
predicate: (joined: string) => boolean,
|
|
38
|
+
timeoutMs = 4000,
|
|
39
|
+
): Promise<void> {
|
|
40
|
+
const deadline = Date.now() + timeoutMs;
|
|
41
|
+
while (Date.now() < deadline) {
|
|
42
|
+
if (predicate(lines.join(""))) return;
|
|
43
|
+
await new Promise((r) => setTimeout(r, 25));
|
|
44
|
+
}
|
|
45
|
+
throw new Error(`timed out waiting for lines; got: ${lines.join("")}`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function isAlive(pid: number): boolean {
|
|
49
|
+
try {
|
|
50
|
+
process.kill(pid, 0);
|
|
51
|
+
return true;
|
|
52
|
+
} catch {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe("spawnManaged", () => {
|
|
58
|
+
it("prefixes every stdout and stderr line with the colored [name] tag", async () => {
|
|
59
|
+
const { proc, lines } = spawnFixture({
|
|
60
|
+
name: "fix",
|
|
61
|
+
script: "console.log('a\\nb'); console.error('c');",
|
|
62
|
+
prefixColor: (s) => `<${s}>`,
|
|
63
|
+
});
|
|
64
|
+
await proc.exited;
|
|
65
|
+
await waitForLines(lines, (all) => all.includes("c"));
|
|
66
|
+
expect(lines).toContain("<[fix]> a\n");
|
|
67
|
+
expect(lines).toContain("<[fix]> b\n");
|
|
68
|
+
expect(lines).toContain("<[fix]> c\n");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("merges opts.env over the parent env", async () => {
|
|
72
|
+
const { proc, lines } = spawnFixture({
|
|
73
|
+
name: "env",
|
|
74
|
+
script: "console.log(process.env.HOGSEND_PROC_TEST);",
|
|
75
|
+
env: { HOGSEND_PROC_TEST: "hello-env" },
|
|
76
|
+
});
|
|
77
|
+
await proc.exited;
|
|
78
|
+
await waitForLines(lines, (all) => all.includes("hello-env"));
|
|
79
|
+
expect(lines.join("")).toContain("[env] hello-env");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("resolves exited and fires onExit with the exit code", async () => {
|
|
83
|
+
const { proc } = spawnFixture({ name: "exit", script: "process.exit(3)" });
|
|
84
|
+
const fromCallback = new Promise<number | null>((resolve) => {
|
|
85
|
+
proc.onExit((info) => resolve(info.code));
|
|
86
|
+
});
|
|
87
|
+
const info = await proc.exited;
|
|
88
|
+
expect(info.code).toBe(3);
|
|
89
|
+
await expect(fromCallback).resolves.toBe(3);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("fires onExit even when registered after the child exited", async () => {
|
|
93
|
+
const { proc } = spawnFixture({ name: "late", script: "" });
|
|
94
|
+
await proc.exited;
|
|
95
|
+
const code = await new Promise<number | null>((resolve) => {
|
|
96
|
+
proc.onExit((info) => resolve(info.code));
|
|
97
|
+
});
|
|
98
|
+
expect(code).toBe(0);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe("shutdownAll", () => {
|
|
103
|
+
it("SIGTERMs a well-behaved child and resolves promptly", async () => {
|
|
104
|
+
const { proc } = spawnFixture({
|
|
105
|
+
name: "loop",
|
|
106
|
+
script: "setInterval(() => {}, 1000); console.log('ready');",
|
|
107
|
+
});
|
|
108
|
+
const pid = proc.child.pid;
|
|
109
|
+
expect(pid).toBeDefined();
|
|
110
|
+
|
|
111
|
+
const started = Date.now();
|
|
112
|
+
await shutdownAll([proc]);
|
|
113
|
+
expect(Date.now() - started).toBeLessThan(4000);
|
|
114
|
+
expect(isAlive(pid as number)).toBe(false);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("SIGKILLs a child that traps SIGTERM after timeoutMs", async () => {
|
|
118
|
+
const { proc, lines } = spawnFixture({
|
|
119
|
+
name: "stubborn",
|
|
120
|
+
script:
|
|
121
|
+
"process.on('SIGTERM', () => {}); setInterval(() => {}, 1000); console.log('ready');",
|
|
122
|
+
});
|
|
123
|
+
// Ensure the SIGTERM handler is installed before we try to kill.
|
|
124
|
+
await waitForLines(lines, (all) => all.includes("ready"));
|
|
125
|
+
const pid = proc.child.pid as number;
|
|
126
|
+
|
|
127
|
+
await shutdownAll([proc], { timeoutMs: 300 });
|
|
128
|
+
expect(isAlive(pid)).toBe(false);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("is idempotent and tolerates already-exited children", async () => {
|
|
132
|
+
const { proc } = spawnFixture({ name: "gone", script: "" });
|
|
133
|
+
await proc.exited;
|
|
134
|
+
await expect(shutdownAll([proc])).resolves.toBeUndefined();
|
|
135
|
+
await expect(shutdownAll([proc])).resolves.toBeUndefined();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("resolves on an empty list", async () => {
|
|
139
|
+
await expect(shutdownAll([])).resolves.toBeUndefined();
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe("waitForHttp", () => {
|
|
144
|
+
it("resolves once the endpoint returns 2xx, polling through 503s", async () => {
|
|
145
|
+
let hits = 0;
|
|
146
|
+
const server = createServer((_req, res) => {
|
|
147
|
+
hits += 1;
|
|
148
|
+
if (hits < 3) {
|
|
149
|
+
res.writeHead(503);
|
|
150
|
+
res.end("not yet");
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
res.writeHead(200);
|
|
154
|
+
res.end("ok");
|
|
155
|
+
});
|
|
156
|
+
await new Promise<void>((r) => server.listen(0, () => r()));
|
|
157
|
+
const port = (server.address() as AddressInfo).port;
|
|
158
|
+
|
|
159
|
+
try {
|
|
160
|
+
await waitForHttp(`http://127.0.0.1:${port}/health`, 10_000);
|
|
161
|
+
expect(hits).toBeGreaterThanOrEqual(3);
|
|
162
|
+
} finally {
|
|
163
|
+
server.close();
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("rejects with the URL and last error after the overall timeout", async () => {
|
|
168
|
+
// Grab a port that is closed (listen then immediately close).
|
|
169
|
+
const server = createServer();
|
|
170
|
+
await new Promise<void>((r) => server.listen(0, () => r()));
|
|
171
|
+
const port = (server.address() as AddressInfo).port;
|
|
172
|
+
await new Promise<void>((r) => server.close(() => r()));
|
|
173
|
+
|
|
174
|
+
const url = `http://127.0.0.1:${port}/health`;
|
|
175
|
+
await expect(waitForHttp(url, 1200)).rejects.toThrow(url);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { createServer, type Server } from "node:net";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
7
|
+
import {
|
|
8
|
+
detectRunningInfra,
|
|
9
|
+
dockerComposeUp,
|
|
10
|
+
ensureAuthSecret,
|
|
11
|
+
ensureEnvFile,
|
|
12
|
+
hasComposeFile,
|
|
13
|
+
probeTcp,
|
|
14
|
+
readDotEnv,
|
|
15
|
+
runMigrations,
|
|
16
|
+
} from "../lib/setup-steps.js";
|
|
17
|
+
|
|
18
|
+
vi.mock("node:child_process", async (importOriginal) => {
|
|
19
|
+
const actual = await importOriginal<typeof import("node:child_process")>();
|
|
20
|
+
return { ...actual, spawnSync: vi.fn() };
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const mockSpawnSync = vi.mocked(spawnSync);
|
|
24
|
+
|
|
25
|
+
type SpawnSyncResult = ReturnType<typeof spawnSync>;
|
|
26
|
+
|
|
27
|
+
function spawnResult(partial: Partial<SpawnSyncResult>): SpawnSyncResult {
|
|
28
|
+
return {
|
|
29
|
+
pid: 1,
|
|
30
|
+
output: [],
|
|
31
|
+
stdout: "",
|
|
32
|
+
stderr: "",
|
|
33
|
+
status: 0,
|
|
34
|
+
signal: null,
|
|
35
|
+
...partial,
|
|
36
|
+
} as SpawnSyncResult;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let cwd: string;
|
|
40
|
+
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
cwd = mkdtempSync(join(tmpdir(), "hogsend-setup-steps-"));
|
|
43
|
+
mockSpawnSync.mockReset();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
afterEach(() => {
|
|
47
|
+
rmSync(cwd, { recursive: true, force: true });
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("ensureEnvFile", () => {
|
|
51
|
+
it("copies .env.example to .env when .env is missing", () => {
|
|
52
|
+
writeFileSync(join(cwd, ".env.example"), "FOO=bar\n");
|
|
53
|
+
const res = ensureEnvFile(cwd);
|
|
54
|
+
expect(res).toEqual({
|
|
55
|
+
step: "env",
|
|
56
|
+
status: "ok",
|
|
57
|
+
detail: "copied .env.example -> .env",
|
|
58
|
+
});
|
|
59
|
+
expect(readFileSync(join(cwd, ".env"), "utf8")).toBe("FOO=bar\n");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("skips when .env already exists", () => {
|
|
63
|
+
writeFileSync(join(cwd, ".env"), "FOO=existing\n");
|
|
64
|
+
const res = ensureEnvFile(cwd);
|
|
65
|
+
expect(res).toEqual({
|
|
66
|
+
step: "env",
|
|
67
|
+
status: "skipped",
|
|
68
|
+
detail: ".env already exists",
|
|
69
|
+
});
|
|
70
|
+
expect(readFileSync(join(cwd, ".env"), "utf8")).toBe("FOO=existing\n");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("fails when neither .env nor .env.example exists", () => {
|
|
74
|
+
const res = ensureEnvFile(cwd);
|
|
75
|
+
expect(res).toEqual({
|
|
76
|
+
step: "env",
|
|
77
|
+
status: "failed",
|
|
78
|
+
detail: "no .env and no .env.example to copy from",
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("ensureAuthSecret", () => {
|
|
84
|
+
it("replaces the change-me placeholder with a 64-char hex secret", () => {
|
|
85
|
+
writeFileSync(
|
|
86
|
+
join(cwd, ".env"),
|
|
87
|
+
"PORT=3002\nBETTER_AUTH_SECRET=change-me-please\n",
|
|
88
|
+
);
|
|
89
|
+
const res = ensureAuthSecret(cwd);
|
|
90
|
+
expect(res).toEqual({
|
|
91
|
+
step: "secret",
|
|
92
|
+
status: "ok",
|
|
93
|
+
detail: "generated BETTER_AUTH_SECRET (64-char hex)",
|
|
94
|
+
});
|
|
95
|
+
const raw = readFileSync(join(cwd, ".env"), "utf8");
|
|
96
|
+
const match = raw.match(/^BETTER_AUTH_SECRET=([0-9a-f]{64})$/m);
|
|
97
|
+
expect(match).not.toBeNull();
|
|
98
|
+
expect(raw).toContain("PORT=3002");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("replaces a REPLACE_ME placeholder (the dogfood .env.example style)", () => {
|
|
102
|
+
// apps/api/.env.example ships BETTER_AUTH_SECRET=REPLACE_ME_RUN_pnpm_gen:secret —
|
|
103
|
+
// it must be treated as a placeholder, not preserved as an invalid secret.
|
|
104
|
+
writeFileSync(
|
|
105
|
+
join(cwd, ".env"),
|
|
106
|
+
"BETTER_AUTH_SECRET=REPLACE_ME_RUN_pnpm_gen:secret\n",
|
|
107
|
+
);
|
|
108
|
+
const res = ensureAuthSecret(cwd);
|
|
109
|
+
expect(res).toEqual({
|
|
110
|
+
step: "secret",
|
|
111
|
+
status: "ok",
|
|
112
|
+
detail: "generated BETTER_AUTH_SECRET (64-char hex)",
|
|
113
|
+
});
|
|
114
|
+
const raw = readFileSync(join(cwd, ".env"), "utf8");
|
|
115
|
+
expect(raw).toMatch(/^BETTER_AUTH_SECRET=[0-9a-f]{64}$/m);
|
|
116
|
+
expect(raw).not.toContain("REPLACE_ME");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("appends the key when it is missing entirely", () => {
|
|
120
|
+
writeFileSync(join(cwd, ".env"), "PORT=3002");
|
|
121
|
+
const res = ensureAuthSecret(cwd);
|
|
122
|
+
expect(res.status).toBe("ok");
|
|
123
|
+
const raw = readFileSync(join(cwd, ".env"), "utf8");
|
|
124
|
+
expect(raw).toMatch(/BETTER_AUTH_SECRET=[0-9a-f]{64}/);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("never overwrites a real secret", () => {
|
|
128
|
+
const real = "a".repeat(64);
|
|
129
|
+
writeFileSync(join(cwd, ".env"), `BETTER_AUTH_SECRET=${real}\n`);
|
|
130
|
+
const res = ensureAuthSecret(cwd);
|
|
131
|
+
expect(res).toEqual({
|
|
132
|
+
step: "secret",
|
|
133
|
+
status: "skipped",
|
|
134
|
+
detail: "BETTER_AUTH_SECRET already set",
|
|
135
|
+
});
|
|
136
|
+
expect(readFileSync(join(cwd, ".env"), "utf8")).toContain(real);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("skips when no .env exists", () => {
|
|
140
|
+
const res = ensureAuthSecret(cwd);
|
|
141
|
+
expect(res).toEqual({
|
|
142
|
+
step: "secret",
|
|
143
|
+
status: "skipped",
|
|
144
|
+
detail: "skipped — no .env",
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
describe("hasComposeFile", () => {
|
|
150
|
+
it("is false in an empty dir", () => {
|
|
151
|
+
expect(hasComposeFile(cwd)).toBe(false);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it.each([
|
|
155
|
+
"docker-compose.yml",
|
|
156
|
+
"docker-compose.yaml",
|
|
157
|
+
"compose.yml",
|
|
158
|
+
"compose.yaml",
|
|
159
|
+
])("detects %s", (name) => {
|
|
160
|
+
writeFileSync(join(cwd, name), "services: {}\n");
|
|
161
|
+
expect(hasComposeFile(cwd)).toBe(true);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe("readDotEnv", () => {
|
|
166
|
+
it("parses KEY=value, export-prefixed lines, quotes and comments", () => {
|
|
167
|
+
writeFileSync(
|
|
168
|
+
join(cwd, ".env"),
|
|
169
|
+
[
|
|
170
|
+
"# comment",
|
|
171
|
+
"PORT=3002",
|
|
172
|
+
"export REDIS_PORT=6380",
|
|
173
|
+
'QUOTED="hello world"',
|
|
174
|
+
"",
|
|
175
|
+
"NOEQ",
|
|
176
|
+
].join("\n"),
|
|
177
|
+
);
|
|
178
|
+
expect(readDotEnv(cwd)).toEqual({
|
|
179
|
+
PORT: "3002",
|
|
180
|
+
REDIS_PORT: "6380",
|
|
181
|
+
QUOTED: "hello world",
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("returns an empty record when no .env exists", () => {
|
|
186
|
+
expect(readDotEnv(cwd)).toEqual({});
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
describe("dockerComposeUp", () => {
|
|
191
|
+
it("returns ok on exit 0", async () => {
|
|
192
|
+
mockSpawnSync.mockReturnValue(spawnResult({ status: 0 }));
|
|
193
|
+
const res = await dockerComposeUp(cwd, { quiet: true });
|
|
194
|
+
expect(res).toEqual({
|
|
195
|
+
step: "docker",
|
|
196
|
+
status: "ok",
|
|
197
|
+
detail: "Postgres + Redis + Hatchet-Lite up",
|
|
198
|
+
});
|
|
199
|
+
expect(mockSpawnSync).toHaveBeenCalledWith(
|
|
200
|
+
"docker",
|
|
201
|
+
["compose", "up", "-d"],
|
|
202
|
+
expect.objectContaining({ cwd, stdio: "ignore" }),
|
|
203
|
+
);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("returns failed with the exit code in detail", async () => {
|
|
207
|
+
mockSpawnSync.mockReturnValue(spawnResult({ status: 1 }));
|
|
208
|
+
const res = await dockerComposeUp(cwd);
|
|
209
|
+
expect(res).toEqual({
|
|
210
|
+
step: "docker",
|
|
211
|
+
status: "failed",
|
|
212
|
+
detail: "docker compose exited with code 1",
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("returns failed with ? when the docker CLI is missing", async () => {
|
|
217
|
+
mockSpawnSync.mockReturnValue(
|
|
218
|
+
spawnResult({ status: null, error: new Error("spawn docker ENOENT") }),
|
|
219
|
+
);
|
|
220
|
+
const res = await dockerComposeUp(cwd);
|
|
221
|
+
expect(res.status).toBe("failed");
|
|
222
|
+
expect(res.detail).toBe("docker compose exited with code ?");
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
describe("runMigrations", () => {
|
|
227
|
+
it("returns ok on exit 0", async () => {
|
|
228
|
+
mockSpawnSync.mockReturnValue(spawnResult({ status: 0 }));
|
|
229
|
+
const res = await runMigrations(cwd, { quiet: true });
|
|
230
|
+
expect(res).toEqual({
|
|
231
|
+
step: "migrate",
|
|
232
|
+
status: "ok",
|
|
233
|
+
detail: "engine + client migrations applied",
|
|
234
|
+
});
|
|
235
|
+
expect(mockSpawnSync).toHaveBeenCalledWith(
|
|
236
|
+
"pnpm",
|
|
237
|
+
["db:migrate"],
|
|
238
|
+
expect.objectContaining({ cwd, stdio: "ignore" }),
|
|
239
|
+
);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("returns failed with the exit code in detail", async () => {
|
|
243
|
+
mockSpawnSync.mockReturnValue(spawnResult({ status: 7 }));
|
|
244
|
+
const res = await runMigrations(cwd);
|
|
245
|
+
expect(res).toEqual({
|
|
246
|
+
step: "migrate",
|
|
247
|
+
status: "failed",
|
|
248
|
+
detail: "pnpm db:migrate exited with code 7",
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
describe("probeTcp", () => {
|
|
254
|
+
let server: Server;
|
|
255
|
+
let openPort: number;
|
|
256
|
+
|
|
257
|
+
beforeEach(async () => {
|
|
258
|
+
server = createServer();
|
|
259
|
+
await new Promise<void>((r) => server.listen(0, "127.0.0.1", () => r()));
|
|
260
|
+
openPort = (server.address() as { port: number }).port;
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
afterEach(async () => {
|
|
264
|
+
await new Promise<void>((r) => server.close(() => r()));
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("resolves true for a listening port", async () => {
|
|
268
|
+
await expect(probeTcp({ port: openPort })).resolves.toBe(true);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("resolves false for a closed port", async () => {
|
|
272
|
+
const closer = createServer();
|
|
273
|
+
await new Promise<void>((r) => closer.listen(0, "127.0.0.1", () => r()));
|
|
274
|
+
const closedPort = (closer.address() as { port: number }).port;
|
|
275
|
+
await new Promise<void>((r) => closer.close(() => r()));
|
|
276
|
+
await expect(probeTcp({ port: closedPort })).resolves.toBe(false);
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
describe("detectRunningInfra", () => {
|
|
281
|
+
/** Point all three .env ports at a known-closed port so probes are false. */
|
|
282
|
+
async function closedPort(): Promise<number> {
|
|
283
|
+
const s = createServer();
|
|
284
|
+
await new Promise<void>((r) => s.listen(0, "127.0.0.1", () => r()));
|
|
285
|
+
const port = (s.address() as { port: number }).port;
|
|
286
|
+
await new Promise<void>((r) => s.close(() => r()));
|
|
287
|
+
return port;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
it("maps compose ps line-json output to the three services", async () => {
|
|
291
|
+
mockSpawnSync.mockReturnValue(
|
|
292
|
+
spawnResult({
|
|
293
|
+
status: 0,
|
|
294
|
+
stdout: [
|
|
295
|
+
'{"Service":"postgres","State":"running"}',
|
|
296
|
+
'{"Service":"redis","State":"running"}',
|
|
297
|
+
'{"Service":"hatchet-lite","State":"running"}',
|
|
298
|
+
'{"Service":"hatchet-postgres","State":"running"}',
|
|
299
|
+
].join("\n"),
|
|
300
|
+
}),
|
|
301
|
+
);
|
|
302
|
+
const res = await detectRunningInfra(cwd);
|
|
303
|
+
expect(res).toEqual({ postgres: true, redis: true, hatchet: true });
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it("treats non-running compose states as down (probe fallback false)", async () => {
|
|
307
|
+
const dead = await closedPort();
|
|
308
|
+
writeFileSync(
|
|
309
|
+
join(cwd, ".env"),
|
|
310
|
+
`POSTGRES_PORT=${dead}\nREDIS_PORT=${dead}\nHATCHET_DASHBOARD_PORT=${dead}\n`,
|
|
311
|
+
);
|
|
312
|
+
mockSpawnSync.mockReturnValue(
|
|
313
|
+
spawnResult({
|
|
314
|
+
status: 0,
|
|
315
|
+
stdout: [
|
|
316
|
+
'{"Service":"postgres","State":"running"}',
|
|
317
|
+
'{"Service":"redis","State":"exited"}',
|
|
318
|
+
'{"Service":"hatchet-lite","State":"exited"}',
|
|
319
|
+
].join("\n"),
|
|
320
|
+
}),
|
|
321
|
+
);
|
|
322
|
+
const res = await detectRunningInfra(cwd);
|
|
323
|
+
expect(res).toEqual({ postgres: true, redis: false, hatchet: false });
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it("falls back to .env port probes when the docker CLI is missing", async () => {
|
|
327
|
+
const listener = createServer();
|
|
328
|
+
await new Promise<void>((r) => listener.listen(0, "127.0.0.1", () => r()));
|
|
329
|
+
const open = (listener.address() as { port: number }).port;
|
|
330
|
+
const dead = await closedPort();
|
|
331
|
+
|
|
332
|
+
writeFileSync(
|
|
333
|
+
join(cwd, ".env"),
|
|
334
|
+
`POSTGRES_PORT=${open}\nREDIS_PORT=${dead}\nHATCHET_DASHBOARD_PORT=${dead}\n`,
|
|
335
|
+
);
|
|
336
|
+
mockSpawnSync.mockReturnValue(
|
|
337
|
+
spawnResult({ status: null, error: new Error("spawn docker ENOENT") }),
|
|
338
|
+
);
|
|
339
|
+
|
|
340
|
+
try {
|
|
341
|
+
const res = await detectRunningInfra(cwd);
|
|
342
|
+
expect(res).toEqual({ postgres: true, redis: false, hatchet: false });
|
|
343
|
+
} finally {
|
|
344
|
+
await new Promise<void>((r) => listener.close(() => r()));
|
|
345
|
+
}
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it("never throws, even on garbage compose output", async () => {
|
|
349
|
+
const dead = await closedPort();
|
|
350
|
+
writeFileSync(
|
|
351
|
+
join(cwd, ".env"),
|
|
352
|
+
`POSTGRES_PORT=${dead}\nREDIS_PORT=${dead}\nHATCHET_DASHBOARD_PORT=${dead}\n`,
|
|
353
|
+
);
|
|
354
|
+
mockSpawnSync.mockReturnValue(
|
|
355
|
+
spawnResult({ status: 0, stdout: "not json at all\n{broken" }),
|
|
356
|
+
);
|
|
357
|
+
await expect(detectRunningInfra(cwd)).resolves.toEqual({
|
|
358
|
+
postgres: false,
|
|
359
|
+
redis: false,
|
|
360
|
+
hatchet: false,
|
|
361
|
+
});
|
|
362
|
+
});
|
|
363
|
+
});
|