@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 { 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
+ });
package/src/bin.ts CHANGED
@@ -73,8 +73,15 @@ async function main(): Promise<void> {
73
73
  const flags = parseGlobalFlags(afterToken);
74
74
  const out = createOutput({ json: flags.json });
75
75
 
76
- // `hogsend <cmd> --help` short-circuits to the command's usage block.
77
- if (flags.help) {
76
+ // `hogsend <cmd> --help` short-circuits to the command's usage block — but
77
+ // only when there's no subcommand positional. With a subcommand present
78
+ // (e.g. `hogsend studio admin --help`) we defer to the command so it can
79
+ // render subcommand-specific help, re-injecting `--help` into its argv (bin
80
+ // owns/strips the global flag) so the command re-detects it. Commands
81
+ // without subcommand help just print their top-level usage on seeing it,
82
+ // matching the prior behavior.
83
+ const hasSubcommand = flags.rest.some((token) => !token.startsWith("-"));
84
+ if (flags.help && !hasSubcommand) {
78
85
  out.log(command.usage);
79
86
  return;
80
87
  }
@@ -83,8 +90,11 @@ async function main(): Promise<void> {
83
90
  const http = createAdminClient(cfg);
84
91
  const dataHttp = createDataPlaneClient(cfg);
85
92
 
93
+ const commandArgv =
94
+ flags.help && hasSubcommand ? [...flags.rest, "--help"] : flags.rest;
95
+
86
96
  await command.run({
87
- argv: flags.rest,
97
+ argv: commandArgv,
88
98
  cfg,
89
99
  http,
90
100
  dataHttp,
@@ -0,0 +1,340 @@
1
+ import { parseArgs } from "node:util";
2
+ import { password as passwordPrompt, text } from "@clack/prompts";
3
+ import {
4
+ type AdminRecovery,
5
+ AdminRecoveryConfigError,
6
+ type AdminSummary,
7
+ createAdminRecovery,
8
+ } from "../lib/admin-recovery.js";
9
+ import { color } from "../lib/output.js";
10
+ import { bail } from "../lib/prompt.js";
11
+ import type { CommandContext } from "./types.js";
12
+
13
+ /**
14
+ * `hogsend studio admin <create|reset|list>` — the shell-gated Studio admin
15
+ * recovery primitive. Routed here from the `studio` command's subcommand
16
+ * dispatch.
17
+ *
18
+ * Security posture (see lib/admin-recovery.ts for the invariants):
19
+ * - Gated by holding both DATABASE_URL and BETTER_AUTH_SECRET. No HTTP.
20
+ * - Passwords go ONLY through better-auth's server API (scrypt). No raw SQL.
21
+ * - The password is NEVER echoed (masked prompt) and NEVER logged. When the
22
+ * `--password` flag is used we warn (interactively) about shell history.
23
+ */
24
+
25
+ export const adminUsage = `hogsend studio admin <command> [options]
26
+
27
+ Shell-gated Studio admin recovery (PostHog/GitLab/Rails-style). Constructs a
28
+ better-auth instance directly against your database and uses better-auth's
29
+ server API so password hashing is identical to the running app. NO HTTP and no
30
+ running API are required — this is gated by holding the DB URL and app secret.
31
+
32
+ DATABASE_URL and BETTER_AUTH_SECRET are read from the ENVIRONMENT only (not from
33
+ a .env file). Run with your app env loaded, e.g.
34
+ dotenvx run -- hogsend studio admin create
35
+ railway run hogsend studio admin create
36
+ pnpm studio:admin # the scaffold's env-loaded wrapper
37
+
38
+ Commands:
39
+ ${color.cyan("create")} Create a Studio admin user (the first admin, or another).
40
+ ${color.cyan("reset")} Set a new password for an existing admin (by email).
41
+ ${color.cyan("list")} List existing admins (id, email, name, createdAt). No secrets.
42
+
43
+ Options:
44
+ --email <e> Admin email (required; prompted in a TTY if omitted).
45
+ --name <n> Display name for create (defaults to the email local-part).
46
+ --password <p> Password for create/reset. PREFER the masked prompt — a
47
+ value passed here can leak into your shell history.
48
+ --no-revoke (reset) Keep existing sessions instead of revoking them.
49
+ --database-url <u> Override DATABASE_URL (else read from the environment).
50
+ --json Emit a single JSON result document (non-interactive).
51
+ -h, --help Show this help.
52
+
53
+ Examples:
54
+ hogsend studio admin create --email admin@example.com
55
+ hogsend studio admin reset --email admin@example.com
56
+ hogsend studio admin list --json
57
+
58
+ Security: passwords are written ONLY via better-auth (scrypt) — never raw SQL,
59
+ never plaintext at rest, never logged. Prefer the masked prompt over --password.`;
60
+
61
+ interface AdminFlags {
62
+ email?: string;
63
+ name?: string;
64
+ password?: string;
65
+ revoke: boolean;
66
+ databaseUrl?: string;
67
+ }
68
+
69
+ /** Parse the admin subcommand argv (after the `admin <sub>` tokens). */
70
+ function parseAdminFlags(argv: string[]): AdminFlags {
71
+ const { values } = parseArgs({
72
+ args: argv,
73
+ allowPositionals: true,
74
+ strict: false,
75
+ options: {
76
+ email: { type: "string" },
77
+ name: { type: "string" },
78
+ password: { type: "string" },
79
+ revoke: { type: "boolean", default: true },
80
+ "database-url": { type: "string" },
81
+ },
82
+ });
83
+ return {
84
+ email: typeof values.email === "string" ? values.email : undefined,
85
+ name: typeof values.name === "string" ? values.name : undefined,
86
+ password: typeof values.password === "string" ? values.password : undefined,
87
+ revoke: values.revoke !== false,
88
+ databaseUrl:
89
+ typeof values["database-url"] === "string"
90
+ ? values["database-url"]
91
+ : undefined,
92
+ };
93
+ }
94
+
95
+ /**
96
+ * Resolve the gating env (DATABASE_URL + BETTER_AUTH_SECRET) from flags then
97
+ * `process.env` ONLY — there is no cwd `.env` read here (consistent with
98
+ * `db:migrate`, which also reads the environment directly). Holding both IS the
99
+ * gate; there is no HTTP fallback. Fails fast and clearly if either is missing,
100
+ * telling the operator how to run the command with its env loaded.
101
+ */
102
+ function resolveGatingEnv(
103
+ ctx: CommandContext,
104
+ flags: AdminFlags,
105
+ ): { databaseUrl: string; secret: string; baseURL: string | undefined } {
106
+ const databaseUrl = flags.databaseUrl ?? process.env.DATABASE_URL;
107
+ const secret = process.env.BETTER_AUTH_SECRET;
108
+ const baseURL = process.env.BETTER_AUTH_URL ?? process.env.API_PUBLIC_URL;
109
+
110
+ const missing: string[] = [];
111
+ if (!databaseUrl) missing.push("DATABASE_URL");
112
+ if (!secret) missing.push("BETTER_AUTH_SECRET");
113
+ if (missing.length > 0) {
114
+ ctx.out.fail(
115
+ `${missing.join(" and ")} ${missing.length > 1 ? "are" : "is"} ` +
116
+ "required, and are read from the environment only (not a .env file). " +
117
+ "Run this command with your app env loaded, e.g.\n" +
118
+ " export DATABASE_URL=… BETTER_AUTH_SECRET=…\n" +
119
+ " dotenvx run -- hogsend studio admin create\n" +
120
+ " railway run hogsend studio admin create\n" +
121
+ " pnpm studio:admin # the scaffold's env-loaded wrapper\n" +
122
+ "This command is gated by DB + secret access (no HTTP fallback).",
123
+ );
124
+ }
125
+
126
+ // Non-null assertions are safe: ctx.out.fail above exits the process.
127
+ return {
128
+ databaseUrl: databaseUrl as string,
129
+ secret: secret as string,
130
+ baseURL,
131
+ };
132
+ }
133
+
134
+ /** Prompt for an email in a TTY, or fail with guidance otherwise. */
135
+ async function resolveEmail(
136
+ ctx: CommandContext,
137
+ flags: AdminFlags,
138
+ defaultEmail?: string,
139
+ ): Promise<string> {
140
+ if (flags.email && flags.email.length > 0) return flags.email;
141
+ if (!ctx.out.interactive) {
142
+ ctx.out.fail("--email is required (no TTY to prompt).");
143
+ }
144
+ const value = bail(
145
+ await text({
146
+ message: "Admin email",
147
+ placeholder: defaultEmail ?? "admin@example.com",
148
+ initialValue: defaultEmail,
149
+ validate: (v) =>
150
+ v?.includes("@") ? undefined : "Enter a valid email address.",
151
+ }),
152
+ );
153
+ return value.trim();
154
+ }
155
+
156
+ /**
157
+ * Resolve a password: from the flag if provided (with an interactive shell-
158
+ * history warning), otherwise via a masked prompt typed twice (confirm).
159
+ * NEVER echoed, NEVER logged.
160
+ */
161
+ async function resolvePassword(
162
+ ctx: CommandContext,
163
+ flags: AdminFlags,
164
+ ): Promise<string> {
165
+ if (flags.password && flags.password.length > 0) {
166
+ if (ctx.out.interactive) {
167
+ ctx.out.log(
168
+ color.yellow(
169
+ "warning: --password can leak into your shell history; " +
170
+ "prefer the masked prompt next time.",
171
+ ),
172
+ );
173
+ }
174
+ return flags.password;
175
+ }
176
+ if (!ctx.out.interactive) {
177
+ ctx.out.fail(
178
+ "--password is required (no TTY for the masked prompt). " +
179
+ "Note: a value passed via --password may leak into shell history.",
180
+ );
181
+ }
182
+ const first = bail(
183
+ await passwordPrompt({
184
+ message: "New password (min 8 chars)",
185
+ validate: (v) =>
186
+ v && v.length >= 8 ? undefined : "Password must be at least 8 chars.",
187
+ }),
188
+ );
189
+ const second = bail(
190
+ await passwordPrompt({
191
+ message: "Confirm password",
192
+ validate: (v) => (v === first ? undefined : "Passwords do not match."),
193
+ }),
194
+ );
195
+ if (first !== second) {
196
+ ctx.out.fail("Passwords do not match.");
197
+ }
198
+ return first;
199
+ }
200
+
201
+ /** Render an admin summary for human output (no secrets present in the type). */
202
+ function printAdmin(ctx: CommandContext, action: string, admin: AdminSummary) {
203
+ ctx.out.kv(
204
+ {
205
+ id: admin.id,
206
+ email: admin.email,
207
+ name: admin.name,
208
+ createdAt: admin.createdAt,
209
+ },
210
+ `${action} admin`,
211
+ );
212
+ }
213
+
214
+ async function runCreate(
215
+ ctx: CommandContext,
216
+ flags: AdminFlags,
217
+ recovery: AdminRecovery,
218
+ ): Promise<void> {
219
+ const email = await resolveEmail(ctx, flags);
220
+ const passwordValue = await resolvePassword(ctx, flags);
221
+ const admin = await recovery.create({
222
+ email,
223
+ password: passwordValue,
224
+ name: flags.name,
225
+ });
226
+ if (ctx.json) {
227
+ ctx.out.json({ action: "create", admin });
228
+ return;
229
+ }
230
+ printAdmin(ctx, "Created", admin);
231
+ ctx.out.outro(`${color.green("✓")} Admin created. You can now sign in.`);
232
+ }
233
+
234
+ async function runReset(
235
+ ctx: CommandContext,
236
+ flags: AdminFlags,
237
+ recovery: AdminRecovery,
238
+ ): Promise<void> {
239
+ // If exactly one admin exists and --email is omitted in a TTY, offer it.
240
+ let defaultEmail: string | undefined;
241
+ if (!flags.email && ctx.out.interactive) {
242
+ const admins = await recovery.list();
243
+ if (admins.length === 1) defaultEmail = admins[0]?.email;
244
+ }
245
+ const email = await resolveEmail(ctx, flags, defaultEmail);
246
+ const passwordValue = await resolvePassword(ctx, flags);
247
+ const admin = await recovery.reset({
248
+ email,
249
+ password: passwordValue,
250
+ revokeSessions: flags.revoke,
251
+ });
252
+ if (ctx.json) {
253
+ ctx.out.json({ action: "reset", admin, revokedSessions: flags.revoke });
254
+ return;
255
+ }
256
+ printAdmin(ctx, "Reset password for", admin);
257
+ ctx.out.outro(
258
+ `${color.green("✓")} Password reset.` +
259
+ (flags.revoke ? " Existing sessions were revoked." : ""),
260
+ );
261
+ }
262
+
263
+ async function runList(
264
+ ctx: CommandContext,
265
+ recovery: AdminRecovery,
266
+ ): Promise<void> {
267
+ const admins = await recovery.list();
268
+ if (ctx.json) {
269
+ ctx.out.json(admins);
270
+ return;
271
+ }
272
+ if (admins.length === 0) {
273
+ ctx.out.note(
274
+ "No admins exist yet. Create one with `hogsend studio admin create`.",
275
+ "Admins",
276
+ );
277
+ return;
278
+ }
279
+ ctx.out.table(
280
+ admins.map((a) => ({ ...a })),
281
+ ["id", "email", "name", "createdAt"],
282
+ );
283
+ }
284
+
285
+ /**
286
+ * Entry point for `hogsend studio admin ...`. `argv` is everything AFTER the
287
+ * `admin` token (i.e. the subcommand + its flags). Routed from `studio.ts`.
288
+ */
289
+ export async function runStudioAdmin(
290
+ ctx: CommandContext,
291
+ argv: string[],
292
+ ): Promise<void> {
293
+ const [sub, ...rest] = argv;
294
+
295
+ if (!sub || sub === "-h" || sub === "--help") {
296
+ ctx.out.log(adminUsage);
297
+ if (!sub) return;
298
+ return;
299
+ }
300
+
301
+ if (!["create", "reset", "list"].includes(sub)) {
302
+ ctx.out.log(adminUsage);
303
+ ctx.out.fail(`unknown subcommand "${sub}"`);
304
+ }
305
+
306
+ // Per-subcommand --help.
307
+ if (rest.includes("-h") || rest.includes("--help")) {
308
+ ctx.out.log(adminUsage);
309
+ return;
310
+ }
311
+
312
+ const flags = parseAdminFlags(rest);
313
+ const env = resolveGatingEnv(ctx, flags);
314
+
315
+ let recovery: AdminRecovery;
316
+ try {
317
+ recovery = createAdminRecovery({
318
+ databaseUrl: env.databaseUrl,
319
+ secret: env.secret,
320
+ baseURL: env.baseURL,
321
+ });
322
+ } catch (err) {
323
+ if (err instanceof AdminRecoveryConfigError) {
324
+ ctx.out.fail(err.message);
325
+ }
326
+ throw err;
327
+ }
328
+
329
+ try {
330
+ if (sub === "create") {
331
+ await runCreate(ctx, flags, recovery);
332
+ } else if (sub === "reset") {
333
+ await runReset(ctx, flags, recovery);
334
+ } else {
335
+ await runList(ctx, recovery);
336
+ }
337
+ } finally {
338
+ await recovery.close();
339
+ }
340
+ }
@@ -5,10 +5,15 @@ import { extname, join, normalize, resolve, sep } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { parseArgs } from "node:util";
7
7
  import { color } from "../lib/output.js";
8
+ import { runStudioAdmin } from "./studio-admin.js";
8
9
  import type { Command, CommandContext } from "./types.js";
9
10
 
10
11
  const usage = `hogsend studio [options]
11
12
 
13
+ Subcommands:
14
+ admin create | reset | list Shell-gated Studio admin recovery (DB + secret).
15
+ Run \`hogsend studio admin --help\` for details.
16
+
12
17
  Serve the bundled Hogsend Studio (the admin SPA) locally and open it in a
13
18
  browser. The Studio is a static single-page app; this command starts a tiny
14
19
  local web server for it on a port of your choosing.
@@ -126,6 +131,14 @@ function openBrowser(url: string): void {
126
131
  }
127
132
 
128
133
  async function run(ctx: CommandContext): Promise<void> {
134
+ // Subcommand dispatch: `hogsend studio admin <create|reset|list> ...`.
135
+ // Route before the static-server flag parsing so the admin flags are owned
136
+ // by the admin handler, not the server.
137
+ if (ctx.argv[0] === "admin") {
138
+ await runStudioAdmin(ctx, ctx.argv.slice(1));
139
+ return;
140
+ }
141
+
129
142
  const { values, positionals } = parseArgs({
130
143
  args: ctx.argv,
131
144
  allowPositionals: true,
@@ -232,7 +245,10 @@ async function run(ctx: CommandContext): Promise<void> {
232
245
  "<instance>, or open <instance>/studio directly.",
233
246
  ),
234
247
  "",
235
- color.dim("First load shows a create-admin screen if no admin exists."),
248
+ color.dim(
249
+ "No admin yet? First load shows a read-only info screen pointing to " +
250
+ "'hogsend studio admin create' — there is no web sign-up.",
251
+ ),
236
252
  color.dim("Press Ctrl+C to stop."),
237
253
  ].join("\n"),
238
254
  "Studio",