@hogsend/cli 0.10.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.
- package/dist/bin.js +10913 -328
- package/dist/bin.js.map +1 -1
- package/package.json +6 -3
- package/skills/hogsend-authoring-emails/references/tracking-and-unsubscribe.md +5 -4
- package/skills/hogsend-cli/SKILL.md +32 -1
- package/skills/hogsend-extending/SKILL.md +4 -1
- package/skills/hogsend-extending/references/swap-a-provider.md +273 -51
- package/src/__tests__/admin-recovery.test.ts +193 -0
- package/src/bin.ts +13 -3
- package/src/commands/studio-admin.ts +340 -0
- package/src/commands/studio.ts +17 -1
- package/src/lib/admin-recovery.ts +193 -0
- package/studio/assets/index-BBOTQnww.js +250 -0
- package/studio/assets/index-DnfpcXbb.css +1 -0
- package/studio/index.html +2 -2
- package/studio/assets/index-BNDE5JtQ.css +0 -1
- 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
|
+
});
|
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
|
-
|
|
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:
|
|
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
|
+
}
|
package/src/commands/studio.ts
CHANGED
|
@@ -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(
|
|
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",
|