Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion betterbase/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ From the monorepo root:
- `bun install`
- `bun run dev`
- `bun run build`
- `bun run typecheck` (runs `turbo run typecheck --filter '*'`)
- `bun run typecheck` (runs `turbo run typecheck --filter "*"`)

> Note: `templates/base` is not in the root workspace graph (`apps/*`, `packages/*`), so run template checks separately (e.g. `cd templates/base && bun run typecheck`).

Expand All @@ -41,3 +41,14 @@ From `templates/base`:
- `bun run build`
- `bun run start`
- `bun run typecheck`


## CLI Highlights

- `bb auth setup [project-root]` — scaffold BetterAuth tables, middleware, and routes.
- Example: `bun run --cwd packages/cli dev auth setup ../../templates/base`
- `bb generate crud <table-name> [project-root]` — generate CRUD routes for a schema table.
- Example: `bun run --cwd packages/cli dev generate crud users ../../templates/base`

Realtime support is built into the base template via `/ws` and `src/lib/realtime.ts`. Generated CRUD routes broadcast insert/update/delete events to subscribers.
For command details and flags, run `bb --help`, `bb auth --help`, and `bb generate --help`.
2 changes: 1 addition & 1 deletion betterbase/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"build": "turbo run build",
"dev": "turbo run dev --parallel",
"lint": "turbo run lint",
"typecheck": "turbo run typecheck --filter '*'"
"typecheck": "turbo run typecheck --filter \"*\""
},
"devDependencies": {
"turbo": "^2.0.0",
Expand Down
2 changes: 1 addition & 1 deletion betterbase/packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"commander": "^12.1.0",
"inquirer": "^10.2.2",
"zod": "^3.23.8",
"typescript": "^5.3.0"
"typescript": "^5.8.0"
},
"devDependencies": {
"@types/bun": "^1.3.9"
Expand Down
171 changes: 155 additions & 16 deletions betterbase/packages/cli/src/commands/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,20 @@ const loginSchema = z.object({
});

authRoute.post('/signup', async (c) => {
const body = signupSchema.parse(await c.req.json());
let rawBody: unknown;
try {
rawBody = await c.req.json();
} catch (err) {
const details = err instanceof Error ? err.message : String(err);
return c.json({ error: 'Invalid JSON', details }, 400);
}

const result = signupSchema.safeParse(rawBody);
if (!result.success) {
return c.json({ error: 'Invalid signup payload', details: result.error.format() }, 400);
}

const body = result.data;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const passwordHash = await Bun.password.hash(body.password);

const created = await db
Expand All @@ -56,17 +69,35 @@ authRoute.post('/signup', async (c) => {
})
.returning();

const createdUser = created[0];
if (!createdUser || typeof createdUser !== 'object') {
return c.json({ error: 'Failed to create user record' }, 500);
}

return c.json({
user: {
id: created[0].id,
email: created[0].email,
name: created[0].name,
id: createdUser.id,
email: createdUser.email,
name: createdUser.name,
},
}, 201);
});

authRoute.post('/login', async (c) => {
const body = loginSchema.parse(await c.req.json());
let rawBody: unknown;
try {
rawBody = await c.req.json();
} catch (err) {
const details = err instanceof Error ? err.message : String(err);
return c.json({ error: 'Invalid JSON', details }, 400);
}

const result = loginSchema.safeParse(rawBody);
if (!result.success) {
return c.json({ error: 'Invalid login payload', details: result.error.format() }, 400);
}

const body = result.data;

const user = await db.select().from(users).where(eq(users.email, body.email)).limit(1);
if (user.length === 0 || !user[0].passwordHash) {
Expand Down Expand Up @@ -142,7 +173,16 @@ async function validateSession(token: string): Promise<AuthContext['user'] | nul

if (session.length === 0) return null;

const user = await db.select().from(users).where(eq(users.id, session[0].userId)).limit(1);
const user = await db
.select({
id: users.id,
email: users.email,
name: users.name,
})
.from(users)
.where(eq(users.id, session[0].userId))
.limit(1);

return user.length > 0 ? user[0] : null;
}

Expand Down Expand Up @@ -196,14 +236,99 @@ function ensurePasswordHashColumn(schemaPath: string): void {
return;
}

const usersBlock = current.match(/export\s+const\s+users\s*=\s*sqliteTable\([^]+?\}\);/m);
if (!usersBlock) {
const usersExportIdx = current.search(/export\s+const\s+users\s*=\s*sqliteTable\s*\(/);
if (usersExportIdx === -1) {
logger.warn('Could not find sqlite users table block; skipping passwordHash injection.');
return;
}

const replacement = usersBlock[0].replace(/\n\}\);$/, "\n passwordHash: text('password_hash').notNull(),\n});");
writeFileSync(schemaPath, current.replace(usersBlock[0], replacement));
const callStart = current.indexOf('sqliteTable(', usersExportIdx);
if (callStart === -1) {
logger.warn('Could not locate sqliteTable call for users; skipping passwordHash injection.');
return;
}

let i = callStart;
let parenDepth = 0;
let inSingle = false;
let inDouble = false;
let inBacktick = false;
let escaped = false;

while (i < current.length) {
const ch = current[i];
const next = current[i + 1];

if (escaped) {
escaped = false;
i += 1;
continue;
}

if ((inSingle || inDouble || inBacktick) && ch === '\\') {
escaped = true;
i += 1;
continue;
}

if (!inDouble && !inBacktick && ch === "'") {
if (inSingle && next === "'") {
i += 2;
continue;
}
inSingle = !inSingle;
i += 1;
continue;
}

if (!inSingle && !inBacktick && ch === '"') {
inDouble = !inDouble;
i += 1;
continue;
}

if (!inSingle && !inDouble && ch === '`') {
inBacktick = !inBacktick;
i += 1;
continue;
}

if (inSingle || inDouble || inBacktick) {
i += 1;
continue;
}

if (ch === '(') {
parenDepth += 1;
} else if (ch === ')') {
parenDepth -= 1;
if (parenDepth === 0) {
break;
}
}

i += 1;
}

if (i >= current.length || parenDepth !== 0) {
logger.warn('Could not safely parse users sqliteTable block; skipping passwordHash injection.');
return;
}

const statementEnd = current.indexOf(';', i);
if (statementEnd === -1) {
logger.warn('Could not locate end of users sqliteTable statement; skipping passwordHash injection.');
return;
}

const usersBlock = current.slice(usersExportIdx, statementEnd + 1);
const replacement = usersBlock.replace(/\n\}\);\s*$/, "\n passwordHash: text('password_hash').notNull(),\n});");
if (replacement === usersBlock) {
logger.warn('Could not inject passwordHash into users table; block layout was unexpected.');
return;
}

writeFileSync(schemaPath, `${current.slice(0, usersExportIdx)}${replacement}${current.slice(statementEnd + 1)}`);
Comment on lines +239 to +331
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# First, let's find the auth.ts file and examine the context
find . -name "auth.ts" -path "*/cli/src/commands/*" 2>/dev/null | head -5

Repository: Helal-maker/Betterbase

Length of output: 112


🏁 Script executed:

# Search for sqliteTable calls in the codebase to see actual usage patterns
rg "sqliteTable\s*\(" -A 3 -B 1 --max-count=20

Repository: Helal-maker/Betterbase

Length of output: 5858


🏁 Script executed:

# More specifically, search for 3-argument sqliteTable patterns (with callbacks)
rg "sqliteTable\s*\(\s*['\"]" -A 5 | head -50

Repository: Helal-maker/Betterbase

Length of output: 3631


🏁 Script executed:

# Read the exact lines 239-331 in auth.ts
sed -n '239,331p' betterbase/packages/cli/src/commands/auth.ts

Repository: Helal-maker/Betterbase

Length of output: 2444


🏁 Script executed:

# Also search for all sqliteTable calls with 3 arguments to confirm they exist
rg "sqliteTable\s*\(['\"].*?['\"],\s*\{[^}]*\},\s*\(" -A 3 --max-count=10

Repository: Helal-maker/Betterbase

Length of output: 48


🏁 Script executed:

# Let me verify the exact pattern when a 3-arg sqliteTable call is used
cat -n betterbase/packages/cli/test/scanner.test.ts | sed -n '20,35p'

Repository: Helal-maker/Betterbase

Length of output: 822


🏁 Script executed:

# Extract a more complete view of the 3-arg sqliteTable usage in the test
rg -A 10 "export const users = sqliteTable\('users'" betterbase/packages/cli/test/scanner.test.ts | head -15

Repository: Helal-maker/Betterbase

Length of output: 539


🏁 Script executed:

# Test the proposed regex patterns to see if they correctly detect callbacks
python3 << 'EOF'
import re

# Sample 3-arg sqliteTable code
code_3arg = """}), (table) => ({
    usersEmailIdx: index('users_email_idx').on(table.email),
  }));"""

code_2arg = """}
);"""

# Test the proposed patterns
pattern1 = r'/\},\s*\(\s*\w+\s*\)\s*=>/'
pattern1_fixed = r'\},\s*\(\s*\w+\s*\)\s*=>'
pattern2_fixed = r'\},\s*function\s*\('

print("Testing with 3-arg callback:")
print(f"Pattern 1 match: {bool(re.search(pattern1_fixed, code_3arg))}")
print(f"Pattern 2 match: {bool(re.search(pattern2_fixed, code_3arg))}")

print("\nTesting with 2-arg (no callback):")
print(f"Pattern 1 match: {bool(re.search(pattern1_fixed, code_2arg))}")
print(f"Pattern 2 match: {bool(re.search(pattern2_fixed, code_2arg))}")

# Test the replacement pattern
replacement_pattern = r'\n\}\);\s*$'
test_block = "export const users = sqliteTable('users', {\n  id: text('id'),\n});"
print(f"\nReplacement pattern matches 2-arg: {bool(re.search(replacement_pattern, test_block))}")

test_block_3arg = "export const users = sqliteTable('users', {\n  id: text('id'),\n}), (table) => ({\n  idx: index('idx')\n}));"
print(f"Replacement pattern matches 3-arg: {bool(re.search(replacement_pattern, test_block_3arg))}")
if re.search(replacement_pattern, test_block_3arg):
    print(f"  Would match at: {repr(re.search(replacement_pattern, test_block_3arg).group())}")
EOF

Repository: Helal-maker/Betterbase

Length of output: 303


🏁 Script executed:

# Test corrected patterns for the actual 3-arg sqliteTable format
python3 << 'EOF'
import re

# Real 3-arg sqliteTable code from the test file
code_3arg = """          }, (table) => ({
            usersEmailIdx: index('users_email_idx').on(table.email),
          }));"""

code_2arg = """          }
        );"""

# The actual pattern in the code: }, (table) => ({
pattern_correct = r'\}\),\s*\(\s*\w+\s*\)\s*=>'

print("Testing with actual 3-arg callback pattern:")
print(f"Corrected pattern match: {bool(re.search(pattern_correct, code_3arg))}")
print(f"Match found: {re.search(pattern_correct, code_3arg).group() if re.search(pattern_correct, code_3arg) else 'None'}")

print("\nTesting with 2-arg (no callback):")
print(f"Corrected pattern match: {bool(re.search(pattern_correct, code_2arg))}")

# Test the replacement patterns
print("\n--- Testing replacement patterns ---")
block_2arg = """export const users = sqliteTable('users', {
  id: text('id'),
});"""

block_3arg = """export const users = sqliteTable('users', {
  id: text('id'),
}), (table) => ({
  idx: index('idx')
}));"""

# The code uses this pattern:
replacement_pattern = r'\n\}\);\s*$'

print(f"\n2-arg block ends with: {repr(block_2arg[-15:])}")
print(f"Pattern matches 2-arg: {bool(re.search(replacement_pattern, block_2arg))}")

print(f"\n3-arg block ends with: {repr(block_3arg[-15:])}")
print(f"Pattern matches 3-arg: {bool(re.search(replacement_pattern, block_3arg))}")

# What would happen if we try to inject into 3-arg?
if re.search(replacement_pattern, block_2arg):
    result_2arg = re.sub(replacement_pattern, "\n  passwordHash: text('password_hash').notNull(),\n});", block_2arg)
    print(f"\n2-arg injection result:\n{result_2arg}")

if re.search(replacement_pattern, block_3arg):
    result_3arg = re.sub(replacement_pattern, "\n  passwordHash: text('password_hash').notNull(),\n});", block_3arg)
    print(f"\n3-arg injection result:\n{result_3arg}")
else:
    print(f"\n3-arg block: Pattern does NOT match (good - injection won't happen incorrectly)")
EOF

Repository: Helal-maker/Betterbase

Length of output: 626


🏁 Script executed:

# Let's verify exactly what happens with the actual usersBlock when we have a 3-arg call
python3 << 'EOF'
import re

# Simulate what the code extracts as usersBlock for a 3-arg call
usersBlock_3arg = """export const users = sqliteTable('users', {
  id: text('id'),
  email: text('email').notNull().unique(),
  age: integer('age').default(18),
}, (table) => ({
  usersEmailIdx: index('users_email_idx').on(table.email),
}));"""

usersBlock_2arg = """export const users = sqliteTable('users', {
  id: text('id'),
  email: text('email').notNull().unique(),
  age: integer('age').default(18),
});"""

# The actual replacement pattern from the code
replacement_pattern = r'\n\}\);\s*$'
replacement_text = "\n  passwordHash: text('password_hash').notNull(),\n});"

# Test with 2-arg
result_2arg = usersBlock_2arg.replace(replacement_pattern, replacement_text)
print("2-arg replacement:")
print(f"Changed: {result_2arg != usersBlock_2arg}")
print(f"Result ends with: {repr(result_2arg[-50:])}\n")

# Test with 3-arg
result_3arg = usersBlock_3arg.replace(replacement_pattern, replacement_text)
print("3-arg replacement:")
print(f"Changed: {result_3arg != usersBlock_3arg}")
print(f"Original ends with: {repr(usersBlock_3arg[-50:])}")
if result_3arg == usersBlock_3arg:
    print("⚠️  REPLACEMENT FAILED - would trigger the warning and return early")
else:
    print(f"Result ends with: {repr(result_3arg[-50:])}")

# Now test the proposed detection patterns from the review
print("\n--- Testing proposed detection patterns ---")
pattern1 = r'\},\s*\(\s*\w+\s*\)\s*=>'
pattern2 = r'\},\s*function\s*\('

print(f"Pattern 1 (arrow fn) matches 3-arg: {bool(re.search(pattern1, usersBlock_3arg))}")
print(f"Pattern 2 (function) matches 3-arg: {bool(re.search(pattern2, usersBlock_3arg))}")
EOF

Repository: Helal-maker/Betterbase

Length of output: 477


Provide clearer diagnostics for unsupported 3-arg sqliteTable callbacks.

The code already prevents schema corruption when encountering 3-arg sqliteTable calls with callbacks—the replacement pattern /\n\}\);\s*$/ only matches the 2-arg syntax ending with });, so injection safely fails and logs a generic warning. However, a more specific warning would improve debugging. The suggested detection fix is partially correct: the arrow function pattern (\},\s*\(\s*\w+\s*\)\s*=>) does match the callback syntax, but the function pattern alone is incomplete. Consider adding an explicit callback detection to distinguish this case from other layout failures.

}

function ensureAuthInConfig(projectRoot: string): void {
Expand Down Expand Up @@ -253,17 +378,31 @@ function ensureRoutesIndexHook(projectRoot: string): void {
const routesIndexPath = path.join(projectRoot, 'src/routes/index.ts');
if (!existsSync(routesIndexPath)) return;

let current = readFileSync(routesIndexPath, 'utf-8');
const current = readFileSync(routesIndexPath, 'utf-8');
const importAnchor = "import { usersRoute } from './users';";
const routeAnchor = "app.route('/api/users', usersRoute);";

let next = current;

if (!current.includes("import { authRoute } from './auth';")) {
current = current.replace("import { usersRoute } from './users';", "import { usersRoute } from './users';\nimport { authRoute } from './auth';");
if (!next.includes("import { authRoute } from './auth';")) {
if (next.includes(importAnchor)) {
next = next.replace(importAnchor, `${importAnchor}\nimport { authRoute } from './auth';`);
} else {
logger.warn(`Could not find import anchor in ${routesIndexPath}; skipping auth route import injection.`);
}
}

if (!current.includes("app.route('/auth', authRoute);")) {
current = current.replace("app.route('/api/users', usersRoute);", "app.route('/api/users', usersRoute);\n app.route('/auth', authRoute);");
if (!next.includes("app.route('/auth', authRoute);")) {
if (next.includes(routeAnchor)) {
next = next.replace(routeAnchor, `${routeAnchor}\n app.route('/auth', authRoute);`);
} else {
logger.warn(`Could not find route anchor in ${routesIndexPath}; skipping auth route registration injection.`);
}
}

writeFileSync(routesIndexPath, current);
if (next !== current) {
writeFileSync(routesIndexPath, next);
}
}

export async function runAuthSetupCommand(projectRoot: string = process.cwd()): Promise<void> {
Expand Down
59 changes: 51 additions & 8 deletions betterbase/packages/cli/src/commands/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,19 +72,35 @@ ${updateShape}
});

${tableName}Route.get('/', async (c) => {
const limit = Number(c.req.query('limit') ?? 50);
const offset = Number(c.req.query('offset') ?? 0);
const safeLimit = Number.isFinite(limit) && limit >= 0 ? Math.min(limit, 100) : 50;
const safeOffset = Number.isFinite(offset) && offset >= 0 ? offset : 0;
const DEFAULT_LIMIT = 50;
const MAX_LIMIT = 100;
const DEFAULT_OFFSET = 0;

const paginationSchema = z.object({
limit: z.coerce.number().int().nonnegative().default(DEFAULT_LIMIT),
offset: z.coerce.number().int().nonnegative().default(DEFAULT_OFFSET),
});

const queryParams = c.req.query();
const sort = queryParams.sort;
const paginationResult = paginationSchema.safeParse({
limit: queryParams.limit,
offset: queryParams.offset,
});

if (!paginationResult.success) {
return c.json({ error: 'Invalid pagination params', details: paginationResult.error.format() }, 400);
}

const { limit, offset } = paginationResult.data;
const fetchLimit = Math.min(limit, MAX_LIMIT);
const sort = queryParams.sort;
const filters = Object.entries(queryParams).filter(([key, value]) => key !== 'limit' && key !== 'offset' && key !== 'sort' && value !== undefined);

let query = db.select().from(${tableName}).$dynamic();

if (filters.length > 0) {
// Security note: by default all table columns are filterable. Consider adding a schema scanner
// annotation (e.g., "filterable") and replacing this with an explicit allowlist for sensitive tables.
const conditions = filters
.filter(([key]) => key in ${tableName})
.map(([key, value]) => eq(${tableName}[key as keyof typeof ${tableName}] as never, value as never));
Expand All @@ -102,8 +118,13 @@ ${tableName}Route.get('/', async (c) => {
}
}

const items = await query.limit(safeLimit).offset(safeOffset);
return c.json({ ${tableName}: items, count: items.length, pagination: { limit: safeLimit, offset: safeOffset } });
const items = await query.limit(fetchLimit + 1).offset(offset);
const hasMore = items.length > fetchLimit;

return c.json({
${tableName}: items.slice(0, fetchLimit),
pagination: { limit: fetchLimit, offset, hasMore },
});
});

${tableName}Route.get('/:id', async (c) => {
Expand Down Expand Up @@ -185,7 +206,7 @@ function ensureRealtimeUtility(projectRoot: string): void {
const realtimePath = path.join(projectRoot, 'src/lib/realtime.ts');
if (existsSync(realtimePath)) return;

const canonicalRealtimePath = path.resolve(import.meta.dir, '../../../templates/base/src/lib/realtime.ts');
const canonicalRealtimePath = path.resolve(import.meta.dir, '../../../../templates/base/src/lib/realtime.ts');
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Resolve realtime template path for bundled CLI

ensureRealtimeUtility resolves the canonical template from import.meta.dir using four .. segments, which only works when running from src in dev mode. The shipped bb binary runs from packages/cli/dist (bin points to ./dist/index.js), so this path resolves outside the repo and bb generate crud ... throws Canonical realtime template not found whenever src/lib/realtime.ts is missing.

Useful? React with 👍 / 👎.

if (!existsSync(canonicalRealtimePath)) {
throw new Error(`Canonical realtime template not found at ${canonicalRealtimePath}`);
}
Expand All @@ -195,6 +216,28 @@ function ensureRealtimeUtility(projectRoot: string): void {
}

async function ensureZodValidatorInstalled(projectRoot: string): Promise<void> {
const packageJsonPath = path.join(projectRoot, 'package.json');
const modulePath = path.join(projectRoot, 'node_modules', '@hono', 'zod-validator');

if (existsSync(modulePath)) {
return;
}

if (existsSync(packageJsonPath)) {
try {
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8')) as {
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
};

if (packageJson.dependencies?.['@hono/zod-validator'] || packageJson.devDependencies?.['@hono/zod-validator']) {
return;
}
} catch {
// Fall through to install branch.
}
}

logger.info('Installing @hono/zod-validator...');
const process = Bun.spawn(['bun', 'add', '@hono/zod-validator'], {
cwd: projectRoot,
Expand Down
Loading