frappebun 0.0.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 (66) hide show
  1. package/README.md +72 -0
  2. package/package.json +59 -0
  3. package/src/api/auth.ts +76 -0
  4. package/src/api/index.ts +10 -0
  5. package/src/api/resource.ts +177 -0
  6. package/src/api/route.ts +301 -0
  7. package/src/app/index.ts +6 -0
  8. package/src/app/loader.ts +218 -0
  9. package/src/auth/auth.ts +247 -0
  10. package/src/auth/index.ts +2 -0
  11. package/src/cli/args.ts +40 -0
  12. package/src/cli/bin.ts +12 -0
  13. package/src/cli/commands/add-api.ts +32 -0
  14. package/src/cli/commands/add-doctype.ts +43 -0
  15. package/src/cli/commands/add-page.ts +33 -0
  16. package/src/cli/commands/add-user.ts +96 -0
  17. package/src/cli/commands/dev.ts +71 -0
  18. package/src/cli/commands/drop-site.ts +27 -0
  19. package/src/cli/commands/init.ts +98 -0
  20. package/src/cli/commands/migrate.ts +110 -0
  21. package/src/cli/commands/new-site.ts +61 -0
  22. package/src/cli/commands/routes.ts +56 -0
  23. package/src/cli/commands/use.ts +30 -0
  24. package/src/cli/index.ts +73 -0
  25. package/src/cli/log.ts +13 -0
  26. package/src/cli/scaffold/templates.ts +189 -0
  27. package/src/context.ts +162 -0
  28. package/src/core/doctype/migration/migration.ts +17 -0
  29. package/src/core/doctype/role/role.ts +7 -0
  30. package/src/core/doctype/session/session.ts +16 -0
  31. package/src/core/doctype/user/user.controller.ts +11 -0
  32. package/src/core/doctype/user/user.ts +22 -0
  33. package/src/core/doctype/user_role/user_role.ts +9 -0
  34. package/src/core/doctypes.ts +25 -0
  35. package/src/core/index.ts +1 -0
  36. package/src/database/database.ts +359 -0
  37. package/src/database/filters.ts +131 -0
  38. package/src/database/index.ts +30 -0
  39. package/src/database/query-builder.ts +1118 -0
  40. package/src/database/schema.ts +188 -0
  41. package/src/doctype/define.ts +45 -0
  42. package/src/doctype/discovery.ts +57 -0
  43. package/src/doctype/field.ts +160 -0
  44. package/src/doctype/index.ts +20 -0
  45. package/src/doctype/layout.ts +62 -0
  46. package/src/doctype/query-builder-stub.ts +16 -0
  47. package/src/doctype/registry.ts +106 -0
  48. package/src/doctype/types.ts +407 -0
  49. package/src/document/document.ts +593 -0
  50. package/src/document/index.ts +6 -0
  51. package/src/document/naming.ts +56 -0
  52. package/src/errors.ts +53 -0
  53. package/src/frappe.d.ts +128 -0
  54. package/src/globals.ts +72 -0
  55. package/src/index.ts +112 -0
  56. package/src/migrations/index.ts +11 -0
  57. package/src/migrations/runner.ts +256 -0
  58. package/src/permissions/index.ts +265 -0
  59. package/src/response.ts +100 -0
  60. package/src/server.ts +210 -0
  61. package/src/site.ts +126 -0
  62. package/src/ssr/handler.ts +56 -0
  63. package/src/ssr/index.ts +11 -0
  64. package/src/ssr/page-loader.ts +200 -0
  65. package/src/ssr/renderer.ts +94 -0
  66. package/src/ssr/use-context.ts +41 -0
@@ -0,0 +1,247 @@
1
+ /**
2
+ * Authentication — cookie-based sessions, login/logout, password hashing, API key auth, CSRF.
3
+ */
4
+
5
+ import { randomUUIDv7 } from "bun"
6
+ import { createHash } from "node:crypto"
7
+
8
+ import type { FrappeDatabase } from "../database/database"
9
+
10
+ const SESSION_COOKIE = "sid"
11
+ const SESSION_EXPIRY_HOURS = 24
12
+
13
+ export interface SessionInfo {
14
+ user: string
15
+ sid?: string
16
+ csrfToken?: string
17
+ }
18
+
19
+ export interface LoginResult {
20
+ user: string
21
+ fullName: string
22
+ sid: string
23
+ csrfToken: string
24
+ /** Ready-to-send Set-Cookie header value */
25
+ cookie: string
26
+ }
27
+
28
+ // ─── Typed DB result shapes ────────────────────────────────
29
+
30
+ interface UserRow {
31
+ email: string
32
+ fullName: string
33
+ password: string | null
34
+ enabled: number
35
+ apiKey: string | null
36
+ apiSecret: string | null
37
+ }
38
+
39
+ interface SessionRow {
40
+ name: string
41
+ user: string
42
+ sid: string
43
+ csrfToken: string | null
44
+ }
45
+
46
+ // ─── Public API ───────────────────────────────────────────
47
+
48
+ /**
49
+ * Resolve the current user from an incoming request.
50
+ * Checks API key header first, then session cookie.
51
+ */
52
+ export async function resolveAuth(req: Request, db: FrappeDatabase): Promise<SessionInfo> {
53
+ // 1. API key: Authorization: Token <key>:<secret>
54
+ const authHeader = req.headers.get("Authorization")
55
+ if (authHeader?.startsWith("Token ")) {
56
+ const token = authHeader.slice(6)
57
+ const colonIdx = token.indexOf(":")
58
+ if (colonIdx !== -1) {
59
+ const apiKey = token.slice(0, colonIdx)
60
+ const apiSecret = token.slice(colonIdx + 1)
61
+ const user = await verifyApiKey(apiKey, apiSecret, db)
62
+ if (user) return { user }
63
+ }
64
+ return { user: "Guest" }
65
+ }
66
+
67
+ // 2. Session cookie
68
+ const cookieHeader = req.headers.get("Cookie")
69
+ if (cookieHeader) {
70
+ const sid = parseCookieValue(cookieHeader, SESSION_COOKIE)
71
+ if (sid) {
72
+ const session = await getActiveSession(sid, db)
73
+ if (session) {
74
+ await db.setValue("Session", session.name, "lastActive", new Date().toISOString())
75
+ return {
76
+ user: session.user,
77
+ sid: session.sid,
78
+ csrfToken: session.csrfToken ?? undefined,
79
+ }
80
+ }
81
+ }
82
+ }
83
+
84
+ return { user: "Guest" }
85
+ }
86
+
87
+ /**
88
+ * Authenticate with email and password. Returns session info or null on failure.
89
+ */
90
+ export async function login(
91
+ email: string,
92
+ password: string,
93
+ db: FrappeDatabase,
94
+ req?: Request,
95
+ ): Promise<LoginResult | null> {
96
+ const user = (await db.getValue("User", { email }, [
97
+ "email",
98
+ "fullName",
99
+ "password",
100
+ "enabled",
101
+ ])) as UserRow | null
102
+ if (!user || !user.enabled || !user.password) return null
103
+
104
+ const valid = await Bun.password.verify(password, user.password)
105
+ if (!valid) return null
106
+
107
+ const sid = randomUUIDv7()
108
+ const csrfToken = createHash("sha256")
109
+ .update(`${sid}-${Date.now()}-${Math.random()}`)
110
+ .digest("hex")
111
+ .slice(0, 32)
112
+
113
+ const now = new Date().toISOString()
114
+ const expiresAt = new Date(Date.now() + SESSION_EXPIRY_HOURS * 3_600_000).toISOString()
115
+ const sessionName = randomUUIDv7()
116
+
117
+ db.insertRow("Session", {
118
+ name: sessionName,
119
+ user: email,
120
+ sid,
121
+ device: req ? parseDevice(req) : "desktop",
122
+ ipAddress: req?.headers.get("X-Forwarded-For") ?? "",
123
+ userAgent: req?.headers.get("User-Agent") ?? "",
124
+ expiresAt,
125
+ lastActive: now,
126
+ status: "Active",
127
+ csrfToken,
128
+ creation: now,
129
+ modified: now,
130
+ modifiedBy: email,
131
+ owner: email,
132
+ docstatus: 0,
133
+ idx: 0,
134
+ })
135
+
136
+ await db.setValue("User", email, "lastLogin", now)
137
+
138
+ const cookie = `${SESSION_COOKIE}=${sid}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${SESSION_EXPIRY_HOURS * 3600}`
139
+
140
+ return { user: email, fullName: user.fullName, sid, csrfToken, cookie }
141
+ }
142
+
143
+ /**
144
+ * Invalidate a session. Returns a cookie header that clears the client cookie.
145
+ */
146
+ export async function logout(sid: string, db: FrappeDatabase): Promise<string> {
147
+ const session = await getActiveSession(sid, db)
148
+ if (session) {
149
+ await db.setValue("Session", session.name, "status", "Logged Out")
150
+ }
151
+ return `${SESSION_COOKIE}=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0`
152
+ }
153
+
154
+ /**
155
+ * Hash a password with bcrypt.
156
+ */
157
+ export async function hashPassword(password: string): Promise<string> {
158
+ return Bun.password.hash(password, { algorithm: "bcrypt", cost: 12 })
159
+ }
160
+
161
+ /**
162
+ * Verify CSRF token. Passes for GET/HEAD/OPTIONS, API key auth, and matching tokens.
163
+ */
164
+ export function verifyCsrf(req: Request, expectedToken: string | undefined): boolean {
165
+ const method = req.method.toUpperCase()
166
+ if (method === "GET" || method === "HEAD" || method === "OPTIONS") return true
167
+ if (req.headers.get("Authorization")?.startsWith("Token ")) return true
168
+ if (!expectedToken) return true
169
+ return req.headers.get("X-Frappe-CSRF-Token") === expectedToken
170
+ }
171
+
172
+ /**
173
+ * Create the Administrator user if it doesn't already exist.
174
+ */
175
+ export async function ensureAdminUser(db: FrappeDatabase, password = "admin"): Promise<void> {
176
+ const exists = await db.exists("User", "Administrator")
177
+ if (exists) return
178
+
179
+ const now = new Date().toISOString()
180
+ db.insertRow("User", {
181
+ name: "Administrator",
182
+ email: "Administrator",
183
+ fullName: "Administrator",
184
+ enabled: 1,
185
+ password: await hashPassword(password),
186
+ userType: "System User",
187
+ language: "en",
188
+ creation: now,
189
+ modified: now,
190
+ modifiedBy: "Administrator",
191
+ owner: "Administrator",
192
+ docstatus: 0,
193
+ idx: 0,
194
+ })
195
+ }
196
+
197
+ // ─── Internal helpers ──────────────────────────────────────
198
+
199
+ async function getActiveSession(sid: string, db: FrappeDatabase): Promise<SessionRow | null> {
200
+ try {
201
+ const rows = await db.sql(
202
+ `SELECT "name", "user", "sid", "csrfToken" FROM "Session" WHERE "sid" = ? AND "status" = 'Active' AND "expiresAt" > ? LIMIT 1`,
203
+ [sid, new Date().toISOString()],
204
+ )
205
+ const row = rows[0]
206
+ if (!row) return null
207
+ return {
208
+ name: String(row["name"]),
209
+ user: String(row["user"]),
210
+ sid: String(row["sid"]),
211
+ csrfToken: row["csrfToken"] != null ? String(row["csrfToken"]) : null,
212
+ }
213
+ } catch {
214
+ return null
215
+ }
216
+ }
217
+
218
+ async function verifyApiKey(
219
+ apiKey: string,
220
+ apiSecret: string,
221
+ db: FrappeDatabase,
222
+ ): Promise<string | null> {
223
+ try {
224
+ const user = (await db.getValue("User", { apiKey }, ["email", "apiSecret", "enabled"])) as Pick<
225
+ UserRow,
226
+ "email" | "apiSecret" | "enabled"
227
+ > | null
228
+ if (!user?.enabled || !user.apiSecret) return null
229
+ return (await Bun.password.verify(apiSecret, user.apiSecret)) ? user.email : null
230
+ } catch {
231
+ return null
232
+ }
233
+ }
234
+
235
+ function parseCookieValue(cookieHeader: string, name: string): string | undefined {
236
+ for (const cookie of cookieHeader.split(";")) {
237
+ const eqIdx = cookie.indexOf("=")
238
+ if (eqIdx === -1) continue
239
+ const key = cookie.slice(0, eqIdx).trim()
240
+ if (key === name) return cookie.slice(eqIdx + 1).trim()
241
+ }
242
+ return undefined
243
+ }
244
+
245
+ function parseDevice(req: Request): string {
246
+ return /mobile/i.test(req.headers.get("User-Agent") ?? "") ? "mobile" : "desktop"
247
+ }
@@ -0,0 +1,2 @@
1
+ export { resolveAuth, login, logout, hashPassword, verifyCsrf, ensureAdminUser } from "./auth"
2
+ export type { SessionInfo } from "./auth"
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Tiny argv parser. Supports positional args and `--flag [value]`.
3
+ */
4
+
5
+ export interface CliArgs {
6
+ positional: string[]
7
+ flags: Record<string, string | boolean>
8
+ }
9
+
10
+ export function parseArgs(argv: string[]): CliArgs {
11
+ const positional: string[] = []
12
+ const flags: Record<string, string | boolean> = {}
13
+
14
+ for (let i = 0; i < argv.length; i++) {
15
+ const arg = argv[i]!
16
+ if (arg.startsWith("--")) {
17
+ const key = arg.slice(2)
18
+ const next = argv[i + 1]
19
+ if (next && !next.startsWith("--")) {
20
+ flags[key] = next
21
+ i++
22
+ } else {
23
+ flags[key] = true
24
+ }
25
+ } else {
26
+ positional.push(arg)
27
+ }
28
+ }
29
+
30
+ return { positional, flags }
31
+ }
32
+
33
+ export function flagString(args: CliArgs, key: string, fallback?: string): string | undefined {
34
+ const v = args.flags[key]
35
+ return typeof v === "string" ? v : fallback
36
+ }
37
+
38
+ export function flagBool(args: CliArgs, key: string): boolean {
39
+ return args.flags[key] === true || args.flags[key] === "true"
40
+ }
package/src/cli/bin.ts ADDED
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * frappe — CLI entry point (bin shim).
4
+ * This file is the `bin.frappe` target in package.json.
5
+ */
6
+
7
+ import { main } from "./index.ts"
8
+
9
+ main().catch((err: unknown) => {
10
+ console.error(err instanceof Error ? err.message : String(err))
11
+ process.exit(1)
12
+ })
@@ -0,0 +1,32 @@
1
+ /**
2
+ * `frappe add-api <name>` — scaffold a new API route file.
3
+ */
4
+
5
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs"
6
+ import { join } from "node:path"
7
+
8
+ import { findProjectRoot, readAppMetadata } from "../../app/loader"
9
+ import type { CliArgs } from "../args"
10
+ import { log } from "../log"
11
+ import { apiRouteTpl } from "../scaffold/templates"
12
+
13
+ export function addApi(moduleDir: string, name: string): string {
14
+ const rel = `api/${name}.ts`
15
+ const file = join(moduleDir, rel)
16
+ if (existsSync(file)) throw new Error(`API file already exists: ${rel}`)
17
+ mkdirSync(join(file, ".."), { recursive: true })
18
+ writeFileSync(file, apiRouteTpl())
19
+ return rel
20
+ }
21
+
22
+ export async function run(args: CliArgs): Promise<void> {
23
+ const name = args.positional[0]
24
+ if (!name) {
25
+ log.error("Usage: frappe add-api <name>")
26
+ process.exit(1)
27
+ }
28
+ const root = findProjectRoot()
29
+ const app = readAppMetadata(root)
30
+ const created = addApi(join(root, app.name), name)
31
+ log.success(`${app.name}/${created}`)
32
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * `frappe add-doctype <Name>` — scaffold a DocType directory + schema + controller.
3
+ */
4
+
5
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs"
6
+ import { join } from "node:path"
7
+
8
+ import { findProjectRoot, readAppMetadata } from "../../app/loader"
9
+ import type { CliArgs } from "../args"
10
+ import { log } from "../log"
11
+ import { doctypeControllerTpl, doctypeSchemaTpl, slugify } from "../scaffold/templates"
12
+
13
+ export interface AddDocTypeOptions {
14
+ moduleDir: string
15
+ /** Display name — e.g. "Sales Invoice". */
16
+ name: string
17
+ }
18
+
19
+ export function addDocType(opts: AddDocTypeOptions): string[] {
20
+ const slug = slugify(opts.name)
21
+ const dir = join(opts.moduleDir, "doctype", slug)
22
+ if (existsSync(dir)) throw new Error(`DocType already exists: ${slug}`)
23
+
24
+ mkdirSync(dir, { recursive: true })
25
+ writeFileSync(join(dir, `${slug}.ts`), doctypeSchemaTpl(opts.name))
26
+ writeFileSync(join(dir, `${slug}.controller.ts`), doctypeControllerTpl(opts.name))
27
+ return [`${slug}/${slug}.ts`, `${slug}/${slug}.controller.ts`]
28
+ }
29
+
30
+ export async function run(args: CliArgs): Promise<void> {
31
+ const name = args.positional[0]
32
+ if (!name) {
33
+ log.error('Usage: frappe add-doctype "<Name>"')
34
+ process.exit(1)
35
+ }
36
+
37
+ const root = findProjectRoot()
38
+ const app = readAppMetadata(root)
39
+ const moduleDir = join(root, app.name)
40
+
41
+ const created = addDocType({ moduleDir, name })
42
+ for (const f of created) log.success(`${app.name}/doctype/${f}`)
43
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * `frappe add-page <path>` — scaffold a new SSR page.
3
+ */
4
+
5
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs"
6
+ import { join } from "node:path"
7
+
8
+ import { findProjectRoot, readAppMetadata } from "../../app/loader"
9
+ import type { CliArgs } from "../args"
10
+ import { log } from "../log"
11
+ import { pageTpl } from "../scaffold/templates"
12
+
13
+ export function addPage(moduleDir: string, pagePath: string): string {
14
+ const normalized = pagePath.replace(/^\/+/, "").replace(/\.vue$/, "")
15
+ const rel = `pages/${normalized}.vue`
16
+ const file = join(moduleDir, rel)
17
+ if (existsSync(file)) throw new Error(`Page already exists: ${rel}`)
18
+ mkdirSync(join(file, ".."), { recursive: true })
19
+ writeFileSync(file, pageTpl())
20
+ return rel
21
+ }
22
+
23
+ export async function run(args: CliArgs): Promise<void> {
24
+ const path = args.positional[0]
25
+ if (!path) {
26
+ log.error("Usage: frappe add-page <path>")
27
+ process.exit(1)
28
+ }
29
+ const root = findProjectRoot()
30
+ const app = readAppMetadata(root)
31
+ const created = addPage(join(root, app.name), path)
32
+ log.success(`${app.name}/${created}`)
33
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * `frappe add-user <email> [--name "Full Name"] [--password pw] [--site name]`
3
+ *
4
+ * Creates a User record in the site's database. Useful for local testing
5
+ * of login flows without needing the admin UI.
6
+ *
7
+ * Examples:
8
+ * frappe add-user john@example.com
9
+ * frappe add-user john@example.com --name "John Doe" --password secret123
10
+ * frappe add-user john@example.com --site staging.localhost
11
+ */
12
+
13
+ import { join } from "node:path"
14
+
15
+ import { findProjectRoot } from "../../app/loader"
16
+ import { hashPassword } from "../../auth/auth"
17
+ import { registerCoreDocTypes } from "../../core/doctypes"
18
+ import { FrappeDatabase } from "../../database/database"
19
+ import { installGlobals } from "../../globals"
20
+ import { getDefaultSite } from "../../site"
21
+ import { flagString, type CliArgs } from "../args"
22
+ import { log } from "../log"
23
+
24
+ export interface AddUserOptions {
25
+ db: FrappeDatabase
26
+ email: string
27
+ fullName?: string
28
+ password?: string
29
+ }
30
+
31
+ export async function addUser(opts: AddUserOptions): Promise<void> {
32
+ const { db, email } = opts
33
+ const fullName = opts.fullName ?? email.split("@")[0] ?? email
34
+ const password = opts.password ?? "admin"
35
+
36
+ const exists = await db.exists("User", { email })
37
+ if (exists) throw new Error(`User "${email}" already exists`)
38
+
39
+ const now = new Date().toISOString()
40
+ db.insertRow("User", {
41
+ name: email,
42
+ email,
43
+ fullName,
44
+ enabled: 1,
45
+ password: await hashPassword(password),
46
+ userType: "System User",
47
+ language: "en",
48
+ creation: now,
49
+ modified: now,
50
+ modifiedBy: "Administrator",
51
+ owner: "Administrator",
52
+ docstatus: 0,
53
+ idx: 0,
54
+ })
55
+ }
56
+
57
+ export async function run(args: CliArgs): Promise<void> {
58
+ const email = args.positional[0]
59
+ if (!email) {
60
+ log.error(
61
+ "Usage: frappe add-user <email> [--name <full name>] [--password <pw>] [--site <name>]",
62
+ )
63
+ process.exit(1)
64
+ }
65
+
66
+ const root = findProjectRoot()
67
+ const sitesDir = join(root, "sites")
68
+ const fullName = flagString(args, "name")
69
+ const password = flagString(args, "password")
70
+
71
+ // Resolve site — honour --site flag, otherwise fall back to default site.
72
+ const siteName = flagString(args, "site") ?? getDefaultSite(sitesDir)
73
+ if (!siteName) {
74
+ log.error(
75
+ "Could not determine site. Use --site <name> or set default_site in common_site_config.json.",
76
+ )
77
+ process.exit(1)
78
+ }
79
+
80
+ installGlobals()
81
+ await registerCoreDocTypes()
82
+
83
+ const dbPath = join(sitesDir, siteName, "site.db")
84
+ const db = new FrappeDatabase(dbPath)
85
+
86
+ try {
87
+ db.migrate()
88
+ await addUser({ db, email, fullName, password })
89
+ log.info(``)
90
+ log.success(`${email} created on ${siteName}`)
91
+ if (!password) log.info(` password: admin (use --password to set a custom one)`)
92
+ log.info(``)
93
+ } finally {
94
+ db.close()
95
+ }
96
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * `frappe dev` — load the app, migrate all sites, boot the server.
3
+ *
4
+ * Phase 2 scope: no file watching or HMR.
5
+ */
6
+
7
+ import { join } from "node:path"
8
+
9
+ import { findProjectRoot, loadApp } from "../../app/loader"
10
+ import { registerCoreDocTypes } from "../../core/doctypes"
11
+ import { installGlobals } from "../../globals"
12
+ import { createServer } from "../../server"
13
+ import { createPageMatcher } from "../../ssr/handler"
14
+ import { loadPages } from "../../ssr/page-loader"
15
+ import { flagString, type CliArgs } from "../args"
16
+ import { log } from "../log"
17
+ import { migrate, discoverSites } from "./migrate"
18
+
19
+ export async function run(args: CliArgs): Promise<void> {
20
+ const root = args.flags["root"] ? String(args.flags["root"]) : findProjectRoot()
21
+ const port = Number(flagString(args, "port", process.env["PORT"] ?? "8000"))
22
+ const onlySite = flagString(args, "site")
23
+ const sitesDir = join(root, "sites")
24
+
25
+ installGlobals()
26
+ await registerCoreDocTypes()
27
+
28
+ // ── Load app ──────────────────────────────────────────
29
+ const app = await loadApp(root)
30
+
31
+ const dtLabel = plural(app.loaded.doctypes, "DocType")
32
+ const apiLabel = plural(app.loaded.apiRoutes, "API route")
33
+ const pageLabel = plural(app.loaded.pages, "page")
34
+ log.info(``)
35
+ log.info(` ${app.metadata.title}`)
36
+ log.info(` ${dtLabel} · ${apiLabel} · ${pageLabel}`)
37
+
38
+ // ── Migrate ───────────────────────────────────────────
39
+ log.info(``)
40
+ const sites = onlySite ? [onlySite] : discoverSites(sitesDir)
41
+ const migrationResults = await migrate({ root, sitesDir, site: onlySite, quiet: true })
42
+
43
+ for (const { site, ran, failed } of migrationResults) {
44
+ if (failed) {
45
+ log.error(` ${site} — migration failed`)
46
+ } else if (ran > 0) {
47
+ log.success(` ${site} — ${plural(ran, "migration")} applied`)
48
+ } else {
49
+ log.success(` ${site} — schema up to date`)
50
+ }
51
+ }
52
+
53
+ // ── Boot server ───────────────────────────────────────
54
+ const pages = await loadPages(app.moduleDir)
55
+ const pageMatcher = createPageMatcher(pages)
56
+ const server = await createServer({ port, sitesDir, pageMatcher, pageTitle: app.metadata.title })
57
+
58
+ log.info(``)
59
+ log.info(` Ready on port ${server.port}`)
60
+ log.info(``)
61
+ for (const site of sites) {
62
+ log.info(` http://${site}:${server.port}`)
63
+ }
64
+ log.info(``)
65
+ }
66
+
67
+ // ─── Helpers ──────────────────────────────────────────────
68
+
69
+ function plural(n: number, word: string): string {
70
+ return `${n} ${word}${n === 1 ? "" : "s"}`
71
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * `frappe drop-site <name>` — delete a site directory and its database.
3
+ */
4
+
5
+ import { existsSync, rmSync } from "node:fs"
6
+ import { join } from "node:path"
7
+
8
+ import { findProjectRoot } from "../../app/loader"
9
+ import type { CliArgs } from "../args"
10
+ import { log } from "../log"
11
+
12
+ export function dropSite(sitesDir: string, site: string): void {
13
+ const siteDir = join(sitesDir, site)
14
+ if (!existsSync(siteDir)) throw new Error(`Site does not exist: ${site}`)
15
+ rmSync(siteDir, { recursive: true, force: true })
16
+ }
17
+
18
+ export async function run(args: CliArgs): Promise<void> {
19
+ const site = args.positional[0]
20
+ if (!site) {
21
+ log.error("Usage: frappe drop-site <name>")
22
+ process.exit(1)
23
+ }
24
+ const sitesDir = join(findProjectRoot(), "sites")
25
+ dropSite(sitesDir, site)
26
+ log.success(`dropped ${site}`)
27
+ }