@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,193 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { createDatabase, user } from "@hogsend/db";
3
+ import { createAuth } from "@hogsend/engine/auth";
4
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
5
+ import {
6
+ type AdminRecovery,
7
+ createAdminRecovery,
8
+ } from "../lib/admin-recovery.js";
9
+
10
+ /**
11
+ * Integration test for the shell-gated admin recovery primitive. Runs against
12
+ * the migrated test DB (postgresql://test:test@localhost:5434/test). It proves
13
+ * the FULL security contract:
14
+ * - create then a sign-in-equivalent password verify succeeds (correct scrypt
15
+ * hashing end to end via better-auth's own verify),
16
+ * - reset rotates the password (old fails, new verifies),
17
+ * - the stored credential is a hash, NEVER plaintext,
18
+ * - create of an existing email and reset of a missing email fail clearly,
19
+ * - list returns only non-secret columns.
20
+ *
21
+ * Skips gracefully (reported as infra-error by the runner) if the DB is absent.
22
+ */
23
+
24
+ const DATABASE_URL =
25
+ process.env.DATABASE_URL ?? "postgresql://test:test@localhost:5434/test";
26
+ const SECRET = "test-secret-admin-recovery-0123456789abcdef";
27
+
28
+ async function dbReachable(): Promise<boolean> {
29
+ const { db, client } = createDatabase({ url: DATABASE_URL });
30
+ try {
31
+ await db.select({ id: user.id }).from(user).limit(1);
32
+ return true;
33
+ } catch {
34
+ return false;
35
+ } finally {
36
+ await client.end();
37
+ }
38
+ }
39
+
40
+ let reachable = false;
41
+ let recovery: AdminRecovery;
42
+ // Verification harness: a separate auth instance whose $context.password.verify
43
+ // reproduces what a real sign-in does, without standing up the HTTP app.
44
+ let verifyPassword: (email: string, password: string) => Promise<boolean>;
45
+ let credentialHash: (email: string) => Promise<string | null>;
46
+ let cleanup: (email: string) => Promise<void>;
47
+
48
+ const emails: string[] = [];
49
+ function freshEmail(): string {
50
+ const e = `admin-${randomUUID()}@example.test`;
51
+ emails.push(e);
52
+ return e;
53
+ }
54
+
55
+ beforeAll(async () => {
56
+ reachable = await dbReachable();
57
+ if (!reachable) return;
58
+
59
+ recovery = createAdminRecovery({ databaseUrl: DATABASE_URL, secret: SECRET });
60
+
61
+ const auth = createAuth({
62
+ db: createDatabase({ url: DATABASE_URL }).db,
63
+ secret: SECRET,
64
+ baseURL: "http://localhost:3002",
65
+ });
66
+ const ctx = await auth.$context;
67
+
68
+ verifyPassword = async (email, password) => {
69
+ const found = await ctx.internalAdapter.findUserByEmail(email, {
70
+ includeAccounts: true,
71
+ });
72
+ const cred = found?.accounts?.find((a) => a.providerId === "credential");
73
+ if (!cred?.password) return false;
74
+ return ctx.password.verify({ password, hash: cred.password });
75
+ };
76
+
77
+ credentialHash = async (email) => {
78
+ const found = await ctx.internalAdapter.findUserByEmail(email, {
79
+ includeAccounts: true,
80
+ });
81
+ const cred = found?.accounts?.find((a) => a.providerId === "credential");
82
+ return cred?.password ?? null;
83
+ };
84
+
85
+ cleanup = async (email) => {
86
+ const found = await ctx.internalAdapter.findUserByEmail(email);
87
+ if (!found) return;
88
+ // Delete via the internal adapter; account/session FKs cascade on user
89
+ // delete, but drop accounts first to be safe across adapters.
90
+ await ctx.internalAdapter.deleteAccounts(found.user.id);
91
+ await ctx.internalAdapter.deleteUser(found.user.id);
92
+ };
93
+ }, 30_000);
94
+
95
+ afterAll(async () => {
96
+ if (!reachable) return;
97
+ for (const email of emails) {
98
+ try {
99
+ await cleanup(email);
100
+ } catch {
101
+ // best-effort teardown
102
+ }
103
+ }
104
+ await recovery.close();
105
+ }, 30_000);
106
+
107
+ describe("admin-recovery", () => {
108
+ it("creates an admin whose password verifies via better-auth", async () => {
109
+ if (!reachable) {
110
+ console.warn("test DB unreachable; skipping admin-recovery integration");
111
+ return;
112
+ }
113
+ const email = freshEmail();
114
+ const created = await recovery.create({
115
+ email,
116
+ password: "correct-horse-battery",
117
+ });
118
+ expect(created.email).toBe(email);
119
+ expect(created.id).toBeTruthy();
120
+ // name defaults to the email local-part.
121
+ expect(created.name).toBe(email.split("@")[0]);
122
+
123
+ // End-to-end: the stored hash verifies the correct password (scrypt).
124
+ expect(await verifyPassword(email, "correct-horse-battery")).toBe(true);
125
+ // and rejects the wrong one.
126
+ expect(await verifyPassword(email, "wrong-password")).toBe(false);
127
+
128
+ // The stored credential is a hash, NEVER plaintext.
129
+ const hash = await credentialHash(email);
130
+ expect(hash).toBeTruthy();
131
+ expect(hash).not.toBe("correct-horse-battery");
132
+ expect(hash).not.toContain("correct-horse-battery");
133
+ // better-auth scrypt format is `salt:derivedKey` (both hex).
134
+ expect(hash).toMatch(/^[0-9a-f]+:[0-9a-f]+$/i);
135
+ }, 30_000);
136
+
137
+ it("fails to create a duplicate email and points at reset", async () => {
138
+ if (!reachable) return;
139
+ const email = freshEmail();
140
+ await recovery.create({ email, password: "first-password-123" });
141
+ await expect(
142
+ recovery.create({ email, password: "another-password-123" }),
143
+ ).rejects.toThrow(/already exists.*reset/i);
144
+ }, 30_000);
145
+
146
+ it("resets the password: old fails, new verifies", async () => {
147
+ if (!reachable) return;
148
+ const email = freshEmail();
149
+ await recovery.create({ email, password: "original-pass-123" });
150
+ expect(await verifyPassword(email, "original-pass-123")).toBe(true);
151
+
152
+ const reset = await recovery.reset({
153
+ email,
154
+ password: "rotated-pass-456",
155
+ });
156
+ expect(reset.email).toBe(email);
157
+
158
+ expect(await verifyPassword(email, "original-pass-123")).toBe(false);
159
+ expect(await verifyPassword(email, "rotated-pass-456")).toBe(true);
160
+
161
+ // Still a hash after reset, never plaintext.
162
+ const hash = await credentialHash(email);
163
+ expect(hash).not.toContain("rotated-pass-456");
164
+ expect(hash).toMatch(/^[0-9a-f]+:[0-9a-f]+$/i);
165
+ }, 30_000);
166
+
167
+ it("fails to reset a missing email and points at create", async () => {
168
+ if (!reachable) return;
169
+ await expect(
170
+ recovery.reset({
171
+ email: `missing-${randomUUID()}@example.test`,
172
+ password: "whatever-123",
173
+ }),
174
+ ).rejects.toThrow(/No admin.*create/i);
175
+ }, 30_000);
176
+
177
+ it("lists admins with only non-secret columns", async () => {
178
+ if (!reachable) return;
179
+ const email = freshEmail();
180
+ await recovery.create({ email, password: "listed-pass-123" });
181
+ const admins = await recovery.list();
182
+ const row = admins.find((a) => a.email === email);
183
+ expect(row).toBeTruthy();
184
+ expect(Object.keys(row ?? {}).sort()).toEqual([
185
+ "createdAt",
186
+ "email",
187
+ "id",
188
+ "name",
189
+ ]);
190
+ // No password/hash key ever leaks into the summary shape.
191
+ expect("password" in (row ?? {})).toBe(false);
192
+ }, 30_000);
193
+ });
@@ -0,0 +1,323 @@
1
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import {
6
+ devCommand,
7
+ fetchDomainLine,
8
+ renderDomainLine,
9
+ } from "../commands/dev.js";
10
+ import type { CommandContext } from "../commands/types.js";
11
+ import type { ResolvedConfig } from "../lib/config.js";
12
+ import type { AdminClient, DataPlaneClient, Query } from "../lib/http.js";
13
+ import type { Output } from "../lib/output.js";
14
+
15
+ vi.mock("../lib/proc.js", () => ({
16
+ spawnManaged: vi.fn(),
17
+ shutdownAll: vi.fn(async () => {}),
18
+ waitForHttp: vi.fn(async () => {}),
19
+ }));
20
+
21
+ import { spawnManaged } from "../lib/proc.js";
22
+
23
+ /** Sentinel thrown by the stubbed `out.fail` instead of process.exit(1). */
24
+ class FailSignal extends Error {
25
+ constructor(readonly failMessage: string) {
26
+ super(failMessage);
27
+ this.name = "FailSignal";
28
+ }
29
+ }
30
+
31
+ interface Captured {
32
+ logs: string[];
33
+ jsonDocs: unknown[];
34
+ }
35
+
36
+ function makeCtx(opts: {
37
+ argv: string[];
38
+ json?: boolean;
39
+ adminKey?: string;
40
+ get?: (path: string, query?: Query) => Promise<unknown>;
41
+ post?: (path: string, body: unknown) => Promise<unknown>;
42
+ }): { ctx: CommandContext; captured: Captured } {
43
+ const captured: Captured = { logs: [], jsonDocs: [] };
44
+
45
+ const out: Output = {
46
+ interactive: false,
47
+ isJson: opts.json ?? false,
48
+ intro: () => {},
49
+ step: async <T>(_label: string, fn: () => Promise<T>) => fn(),
50
+ note: (body: string) => {
51
+ captured.logs.push(body);
52
+ },
53
+ table: () => {},
54
+ kv: () => {},
55
+ log: (msg: string) => {
56
+ captured.logs.push(msg);
57
+ },
58
+ json: (payload: unknown) => {
59
+ captured.jsonDocs.push(payload);
60
+ },
61
+ outro: () => {},
62
+ fail: (message: string): never => {
63
+ throw new FailSignal(message);
64
+ },
65
+ };
66
+
67
+ const cfg = {
68
+ baseUrl: "http://localhost:3002",
69
+ adminKey: opts.adminKey,
70
+ dataKey: "hsk_data",
71
+ } as ResolvedConfig;
72
+
73
+ const http = {
74
+ cfg,
75
+ get: (path: string, query?: Query) =>
76
+ (opts.get ?? (() => Promise.reject(new Error("unexpected GET"))))(
77
+ path,
78
+ query,
79
+ ),
80
+ post: () => Promise.reject(new Error("unexpected POST")),
81
+ patch: () => Promise.reject(new Error("unexpected PATCH")),
82
+ del: () => Promise.reject(new Error("unexpected DELETE")),
83
+ } as AdminClient;
84
+
85
+ const dataHttp = {
86
+ cfg,
87
+ get: () => Promise.reject(new Error("unexpected data GET")),
88
+ post: (path: string, body: unknown) =>
89
+ (opts.post ?? (() => Promise.reject(new Error("unexpected data POST"))))(
90
+ path,
91
+ body,
92
+ ),
93
+ put: () => Promise.reject(new Error("unexpected PUT")),
94
+ del: () => Promise.reject(new Error("unexpected DELETE")),
95
+ } as DataPlaneClient;
96
+
97
+ const ctx: CommandContext = {
98
+ argv: opts.argv,
99
+ cfg,
100
+ http,
101
+ dataHttp,
102
+ out,
103
+ json: opts.json ?? false,
104
+ };
105
+
106
+ return { ctx, captured };
107
+ }
108
+
109
+ let cwd: string;
110
+
111
+ beforeEach(() => {
112
+ cwd = mkdtempSync(join(tmpdir(), "hogsend-dev-"));
113
+ vi.mocked(spawnManaged).mockClear();
114
+ vi.unstubAllGlobals();
115
+ });
116
+
117
+ afterEach(() => {
118
+ rmSync(cwd, { recursive: true, force: true });
119
+ vi.unstubAllGlobals();
120
+ });
121
+
122
+ describe("hogsend dev --help", () => {
123
+ it("prints usage and exits cleanly", async () => {
124
+ const { ctx, captured } = makeCtx({ argv: ["--help"] });
125
+ await devCommand.run(ctx);
126
+ const all = captured.logs.join("\n");
127
+ expect(all).toContain("hogsend dev");
128
+ expect(all).toContain("--fire");
129
+ expect(all).toContain("--no-worker");
130
+ expect(all).toContain("--no-infra");
131
+ expect(vi.mocked(spawnManaged)).not.toHaveBeenCalled();
132
+ });
133
+ });
134
+
135
+ describe("hogsend dev app detection", () => {
136
+ it("fails with 'not a Hogsend app' in an empty directory", async () => {
137
+ const { ctx } = makeCtx({ argv: ["--cwd", cwd] });
138
+ await expect(devCommand.run(ctx)).rejects.toThrow(/not a Hogsend app/i);
139
+ expect(vi.mocked(spawnManaged)).not.toHaveBeenCalled();
140
+ });
141
+
142
+ it("names the missing worker:dev script", async () => {
143
+ writeFileSync(
144
+ join(cwd, "package.json"),
145
+ JSON.stringify({
146
+ name: "app",
147
+ scripts: { dev: "tsx watch src/index.ts" },
148
+ dependencies: { "@hogsend/engine": "^0.11.0" },
149
+ }),
150
+ );
151
+ const { ctx } = makeCtx({ argv: ["--cwd", cwd] });
152
+ await expect(devCommand.run(ctx)).rejects.toThrow(/worker:dev/);
153
+ });
154
+
155
+ it("names the missing @hogsend/engine dependency", async () => {
156
+ writeFileSync(
157
+ join(cwd, "package.json"),
158
+ JSON.stringify({
159
+ name: "app",
160
+ scripts: { dev: "x", "worker:dev": "y" },
161
+ dependencies: {},
162
+ }),
163
+ );
164
+ const { ctx } = makeCtx({ argv: ["--cwd", cwd] });
165
+ await expect(devCommand.run(ctx)).rejects.toThrow(/@hogsend\/engine/);
166
+ });
167
+ });
168
+
169
+ describe("hogsend dev --fire", () => {
170
+ it("delegates to the events send path without booting anything", async () => {
171
+ vi.stubGlobal(
172
+ "fetch",
173
+ vi.fn(async () => ({ ok: true })),
174
+ );
175
+ let seenPath: string | undefined;
176
+ let seenBody: unknown;
177
+ const { ctx } = makeCtx({
178
+ argv: ["--fire", "signup", "--email", "a@b.com", "--prop", "plan=pro"],
179
+ post: async (path, body) => {
180
+ seenPath = path;
181
+ seenBody = body;
182
+ return { stored: true, exits: [] };
183
+ },
184
+ });
185
+ await devCommand.run(ctx);
186
+ expect(seenPath).toBe("/v1/events");
187
+ expect(seenBody).toEqual({
188
+ name: "signup",
189
+ email: "a@b.com",
190
+ eventProperties: { plan: "pro" },
191
+ });
192
+ expect(vi.mocked(spawnManaged)).not.toHaveBeenCalled();
193
+ });
194
+
195
+ it("supports --fire=<event> syntax", async () => {
196
+ vi.stubGlobal(
197
+ "fetch",
198
+ vi.fn(async () => ({ ok: true })),
199
+ );
200
+ let seenBody: unknown;
201
+ const { ctx } = makeCtx({
202
+ argv: ["--fire=signup", "--user-id", "u_1"],
203
+ post: async (_path, body) => {
204
+ seenBody = body;
205
+ return { stored: true, exits: [] };
206
+ },
207
+ });
208
+ await devCommand.run(ctx);
209
+ expect(seenBody).toEqual({ name: "signup", userId: "u_1" });
210
+ });
211
+
212
+ it("fails when --fire has no event name", async () => {
213
+ const { ctx } = makeCtx({ argv: ["--fire"] });
214
+ await expect(devCommand.run(ctx)).rejects.toThrow(
215
+ /--fire requires an event name/,
216
+ );
217
+ });
218
+
219
+ it("fails with a friendly hint when the instance is down", async () => {
220
+ vi.stubGlobal(
221
+ "fetch",
222
+ vi.fn(async () => {
223
+ throw new Error("ECONNREFUSED");
224
+ }),
225
+ );
226
+ const { ctx } = makeCtx({
227
+ argv: ["--fire", "signup", "--user-id", "u_1"],
228
+ });
229
+ await expect(devCommand.run(ctx)).rejects.toThrow(
230
+ /is hogsend dev running/i,
231
+ );
232
+ expect(vi.mocked(spawnManaged)).not.toHaveBeenCalled();
233
+ });
234
+ });
235
+
236
+ describe("renderDomainLine", () => {
237
+ it("renders a yellow test-mode line with the redirect target", () => {
238
+ const line = renderDomainLine({
239
+ domain: "mysite.com",
240
+ status: { state: "pending" },
241
+ testMode: { active: true, redirectTo: "doug@x.dev" },
242
+ });
243
+ expect(line).toContain("Test mode active");
244
+ expect(line).toContain("doug@x.dev");
245
+ expect(line).toContain("pending");
246
+ });
247
+
248
+ it("renders a verified domain line", () => {
249
+ const line = renderDomainLine({
250
+ domain: "mysite.com",
251
+ status: { state: "verified" },
252
+ testMode: { active: false, redirectTo: null },
253
+ });
254
+ expect(line).toContain("mysite.com");
255
+ expect(line).toContain("verified");
256
+ });
257
+
258
+ it("returns null when there is nothing to say", () => {
259
+ expect(
260
+ renderDomainLine({
261
+ domain: null,
262
+ status: null,
263
+ testMode: { active: false, redirectTo: null },
264
+ }),
265
+ ).toBeNull();
266
+ });
267
+ });
268
+
269
+ describe("fetchDomainLine (guarded soft-consume of /v1/admin/domain)", () => {
270
+ it("returns null without calling HTTP when no admin key is configured", async () => {
271
+ const get = vi.fn();
272
+ const { ctx } = makeCtx({ argv: [], get });
273
+ await expect(fetchDomainLine(ctx)).resolves.toBeNull();
274
+ expect(get).not.toHaveBeenCalled();
275
+ });
276
+
277
+ it("returns null when the route 404s (engine without domain-setup)", async () => {
278
+ const { ctx } = makeCtx({
279
+ argv: [],
280
+ adminKey: "hsk_admin",
281
+ get: async () => {
282
+ const err = new Error("request failed with status 404");
283
+ (err as Error & { status: number }).status = 404;
284
+ throw err;
285
+ },
286
+ });
287
+ await expect(fetchDomainLine(ctx)).resolves.toBeNull();
288
+ });
289
+
290
+ it("returns the rendered line on success", async () => {
291
+ const { ctx } = makeCtx({
292
+ argv: [],
293
+ adminKey: "hsk_admin",
294
+ get: async (path) => {
295
+ expect(path).toBe("/v1/admin/domain");
296
+ return {
297
+ domain: "mysite.com",
298
+ status: { state: "verified" },
299
+ testMode: { active: false, redirectTo: null },
300
+ };
301
+ },
302
+ });
303
+ const line = await fetchDomainLine(ctx);
304
+ expect(line).toContain("mysite.com");
305
+ });
306
+
307
+ it("returns null on a malformed response body", async () => {
308
+ const { ctx } = makeCtx({
309
+ argv: [],
310
+ adminKey: "hsk_admin",
311
+ get: async () => "weird",
312
+ });
313
+ await expect(fetchDomainLine(ctx)).resolves.toBeNull();
314
+ });
315
+ });
316
+
317
+ describe("hogsend dev infra gating", () => {
318
+ it("requires package.json before doing anything else (cwd is a dir)", async () => {
319
+ mkdirSync(join(cwd, "sub"));
320
+ const { ctx } = makeCtx({ argv: ["--cwd", join(cwd, "sub")] });
321
+ await expect(devCommand.run(ctx)).rejects.toThrow(/package\.json/);
322
+ });
323
+ });