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.
- package/README.md +72 -0
- package/package.json +59 -0
- package/src/api/auth.ts +76 -0
- package/src/api/index.ts +10 -0
- package/src/api/resource.ts +177 -0
- package/src/api/route.ts +301 -0
- package/src/app/index.ts +6 -0
- package/src/app/loader.ts +218 -0
- package/src/auth/auth.ts +247 -0
- package/src/auth/index.ts +2 -0
- package/src/cli/args.ts +40 -0
- package/src/cli/bin.ts +12 -0
- package/src/cli/commands/add-api.ts +32 -0
- package/src/cli/commands/add-doctype.ts +43 -0
- package/src/cli/commands/add-page.ts +33 -0
- package/src/cli/commands/add-user.ts +96 -0
- package/src/cli/commands/dev.ts +71 -0
- package/src/cli/commands/drop-site.ts +27 -0
- package/src/cli/commands/init.ts +98 -0
- package/src/cli/commands/migrate.ts +110 -0
- package/src/cli/commands/new-site.ts +61 -0
- package/src/cli/commands/routes.ts +56 -0
- package/src/cli/commands/use.ts +30 -0
- package/src/cli/index.ts +73 -0
- package/src/cli/log.ts +13 -0
- package/src/cli/scaffold/templates.ts +189 -0
- package/src/context.ts +162 -0
- package/src/core/doctype/migration/migration.ts +17 -0
- package/src/core/doctype/role/role.ts +7 -0
- package/src/core/doctype/session/session.ts +16 -0
- package/src/core/doctype/user/user.controller.ts +11 -0
- package/src/core/doctype/user/user.ts +22 -0
- package/src/core/doctype/user_role/user_role.ts +9 -0
- package/src/core/doctypes.ts +25 -0
- package/src/core/index.ts +1 -0
- package/src/database/database.ts +359 -0
- package/src/database/filters.ts +131 -0
- package/src/database/index.ts +30 -0
- package/src/database/query-builder.ts +1118 -0
- package/src/database/schema.ts +188 -0
- package/src/doctype/define.ts +45 -0
- package/src/doctype/discovery.ts +57 -0
- package/src/doctype/field.ts +160 -0
- package/src/doctype/index.ts +20 -0
- package/src/doctype/layout.ts +62 -0
- package/src/doctype/query-builder-stub.ts +16 -0
- package/src/doctype/registry.ts +106 -0
- package/src/doctype/types.ts +407 -0
- package/src/document/document.ts +593 -0
- package/src/document/index.ts +6 -0
- package/src/document/naming.ts +56 -0
- package/src/errors.ts +53 -0
- package/src/frappe.d.ts +128 -0
- package/src/globals.ts +72 -0
- package/src/index.ts +112 -0
- package/src/migrations/index.ts +11 -0
- package/src/migrations/runner.ts +256 -0
- package/src/permissions/index.ts +265 -0
- package/src/response.ts +100 -0
- package/src/server.ts +210 -0
- package/src/site.ts +126 -0
- package/src/ssr/handler.ts +56 -0
- package/src/ssr/index.ts +11 -0
- package/src/ssr/page-loader.ts +200 -0
- package/src/ssr/renderer.ts +94 -0
- package/src/ssr/use-context.ts +41 -0
package/src/auth/auth.ts
ADDED
|
@@ -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
|
+
}
|
package/src/cli/args.ts
ADDED
|
@@ -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
|
+
}
|