@hogsend/cli 0.9.0 → 0.11.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.
@@ -0,0 +1,193 @@
1
+ import { createDatabase, user } from "@hogsend/db";
2
+ // Narrow subpath imports: `@hogsend/engine/auth` re-exports only `createAuth`
3
+ // (lib/auth.ts) and `@hogsend/engine/create-admin` only `createAdminUser`
4
+ // (lib/create-admin.ts) — both module graphs touch just better-auth +
5
+ // @hogsend/db. Importing from the engine barrel (`@hogsend/engine`) would
6
+ // eagerly run the env validation in env.ts (requires BETTER_AUTH_SECRET /
7
+ // HATCHET_CLIENT_TOKEN at module-eval time) and pull Hatchet/Resend/PostHog —
8
+ // heavy and wrong here.
9
+ import { createAuth } from "@hogsend/engine/auth";
10
+ import {
11
+ AdminAlreadyExistsError,
12
+ createAdminUser,
13
+ } from "@hogsend/engine/create-admin";
14
+
15
+ /**
16
+ * Shell-gated Studio admin recovery primitive (PostHog/GitLab/Rails-style
17
+ * management command). Constructs its own better-auth instance against the DB
18
+ * and uses better-auth's SERVER API so password hashing is identical to the
19
+ * running app. NO HTTP, no running API required.
20
+ *
21
+ * Security invariants (these are acceptance gates, not preferences):
22
+ * - Every password write goes through better-auth's server API (scrypt via
23
+ * `ctx.password.hash` + the internal adapter). Public sign-up is closed
24
+ * (`disableSignUp`), so create() uses the internal adapter too, NOT
25
+ * `auth.api.signUpEmail`. There are NO raw SQL password writes here, ever.
26
+ * - Passwords are never logged and never returned in any result object.
27
+ * - `list` selects only non-secret columns (id/email/name/createdAt) — never
28
+ * the account password/hash.
29
+ * - Gated by holding both `DATABASE_URL` and `BETTER_AUTH_SECRET` (i.e. DB
30
+ * reach + the app secret). There is no HTTP fallback.
31
+ */
32
+
33
+ /** A single admin row, as surfaced by `list` (no secrets). */
34
+ export interface AdminSummary {
35
+ id: string;
36
+ email: string;
37
+ name: string;
38
+ createdAt: string;
39
+ }
40
+
41
+ export interface AdminRecovery {
42
+ /**
43
+ * Create a new admin user via better-auth's INTERNAL ADAPTER (scrypt-hashes,
44
+ * writes the `user` + `account` rows). NOT via public sign-up — that is now
45
+ * blocked by `disableSignUp`; the internal-adapter path is not subject to that
46
+ * guard and is correct for the trusted CLI. Throws a clear, non-secret error
47
+ * if the email already exists (points at `reset`).
48
+ */
49
+ create(input: {
50
+ email: string;
51
+ password: string;
52
+ name?: string;
53
+ }): Promise<AdminSummary>;
54
+ /**
55
+ * Set the password for an existing admin. Mirrors better-auth's own
56
+ * `resetPassword` route: hash via `ctx.password.hash`, then either
57
+ * `updatePassword` (if a credential account exists) or `createAccount` a
58
+ * credential account. Optionally revokes existing sessions so an old leaked
59
+ * session cannot survive a recovery reset. Throws if no user matches.
60
+ */
61
+ reset(input: {
62
+ email: string;
63
+ password: string;
64
+ revokeSessions?: boolean;
65
+ }): Promise<AdminSummary>;
66
+ /** List existing admins (no secret columns selected, ever). */
67
+ list(): Promise<AdminSummary[]>;
68
+ /** Close the pg pool so the CLI process exits cleanly. */
69
+ close(): Promise<void>;
70
+ }
71
+
72
+ /** Thrown when required env (DB URL / app secret) is missing. */
73
+ export class AdminRecoveryConfigError extends Error {}
74
+
75
+ /**
76
+ * Resolve a single admin row from a better-auth `User`-shaped object into the
77
+ * non-secret summary shape.
78
+ */
79
+ function toSummary(u: {
80
+ id: string;
81
+ email: string;
82
+ name: string;
83
+ createdAt: Date | string;
84
+ }): AdminSummary {
85
+ const created =
86
+ u.createdAt instanceof Date ? u.createdAt.toISOString() : u.createdAt;
87
+ return { id: u.id, email: u.email, name: u.name, createdAt: created };
88
+ }
89
+
90
+ /**
91
+ * Build an {@link AdminRecovery} bound to a DB + app secret. Constructs a
92
+ * minimal better-auth instance directly (NOT `createHogsendClient`, which boots
93
+ * Hatchet/Resend/PostHog and is heavy + irrelevant here).
94
+ *
95
+ * `baseURL` is only used by better-auth for cookie/URL config and is irrelevant
96
+ * to these headless server calls; it defaults to localhost.
97
+ */
98
+ export function createAdminRecovery(opts: {
99
+ databaseUrl: string;
100
+ secret: string;
101
+ baseURL?: string;
102
+ }): AdminRecovery {
103
+ if (!opts.databaseUrl) {
104
+ throw new AdminRecoveryConfigError("DATABASE_URL is required.");
105
+ }
106
+ if (!opts.secret) {
107
+ throw new AdminRecoveryConfigError("BETTER_AUTH_SECRET is required.");
108
+ }
109
+
110
+ const { db, client } = createDatabase({ url: opts.databaseUrl });
111
+ const auth = createAuth({
112
+ db,
113
+ secret: opts.secret,
114
+ baseURL: opts.baseURL ?? "http://localhost:3002",
115
+ });
116
+
117
+ return {
118
+ async create({ email, password, name }) {
119
+ try {
120
+ // Shared scrypt-correct minting via the internal adapter (NOT public
121
+ // sign-up, which `disableSignUp` now blocks). `CreatedAdmin` is the same
122
+ // non-secret shape as `AdminSummary` (id/email/name/createdAt string).
123
+ return await createAdminUser({ auth, email, name, password });
124
+ } catch (err) {
125
+ // Re-message a duplicate without leaking internals; never echo the
126
+ // password. `createAdminUser` throws AdminAlreadyExistsError with a
127
+ // message that already points at `reset`.
128
+ if (err instanceof AdminAlreadyExistsError) {
129
+ throw new Error(err.message);
130
+ }
131
+ if (err instanceof Error) {
132
+ throw new Error(`Failed to create admin: ${err.message}`);
133
+ }
134
+ throw err;
135
+ }
136
+ },
137
+
138
+ async reset({ email, password, revokeSessions }) {
139
+ const ctx = await auth.$context;
140
+ const found = await ctx.internalAdapter.findUserByEmail(email, {
141
+ includeAccounts: true,
142
+ });
143
+ if (!found) {
144
+ throw new Error(
145
+ `No admin with email "${email}". ` +
146
+ "Use `hogsend studio admin create` to create one.",
147
+ );
148
+ }
149
+
150
+ // Hash via better-auth's server API — scrypt, identical to the running
151
+ // app. NO raw SQL password write.
152
+ const hashed = await ctx.password.hash(password);
153
+ const hasCredential = found.accounts?.some(
154
+ (a) => a.providerId === "credential",
155
+ );
156
+ if (hasCredential) {
157
+ await ctx.internalAdapter.updatePassword(found.user.id, hashed);
158
+ } else {
159
+ await ctx.internalAdapter.createAccount({
160
+ userId: found.user.id,
161
+ providerId: "credential",
162
+ accountId: found.user.id,
163
+ password: hashed,
164
+ });
165
+ }
166
+
167
+ // A recovery reset should not leave old (possibly leaked) sessions alive.
168
+ if (revokeSessions) {
169
+ await ctx.internalAdapter.deleteSessions(found.user.id);
170
+ }
171
+
172
+ return toSummary(found.user);
173
+ },
174
+
175
+ async list() {
176
+ // Plain Drizzle read — only non-secret columns. The password/hash column
177
+ // lives on `account` and is never selected here.
178
+ const rows = await db
179
+ .select({
180
+ id: user.id,
181
+ email: user.email,
182
+ name: user.name,
183
+ createdAt: user.createdAt,
184
+ })
185
+ .from(user);
186
+ return rows.map((r) => toSummary(r));
187
+ },
188
+
189
+ async close() {
190
+ await client.end();
191
+ },
192
+ };
193
+ }