@vibiz/cli 0.0.0-stage → 0.1.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 (43) hide show
  1. package/README.md +36 -2
  2. package/dist/chunk-NWL4LVVI.js +1015 -0
  3. package/dist/src-BOTTM5XC.js +2230 -0
  4. package/dist/vibiz.js +3302 -0
  5. package/package.json +37 -4
  6. package/templates/nextjs-saas/.env.example +13 -0
  7. package/templates/nextjs-saas/README.md +106 -0
  8. package/templates/nextjs-saas/_gitignore +8 -0
  9. package/templates/nextjs-saas/_npmrc +1 -0
  10. package/templates/nextjs-saas/app/api/auth/[...path]/route.ts +30 -0
  11. package/templates/nextjs-saas/app/api/chat/route.ts +76 -0
  12. package/templates/nextjs-saas/app/dashboard/actions.ts +32 -0
  13. package/templates/nextjs-saas/app/dashboard/page.tsx +130 -0
  14. package/templates/nextjs-saas/app/globals.css +17 -0
  15. package/templates/nextjs-saas/app/layout.tsx +28 -0
  16. package/templates/nextjs-saas/app/not-found.tsx +13 -0
  17. package/templates/nextjs-saas/app/page.tsx +63 -0
  18. package/templates/nextjs-saas/app/sign-in/page.tsx +8 -0
  19. package/templates/nextjs-saas/app/sign-up/page.tsx +8 -0
  20. package/templates/nextjs-saas/app.config.ts +15 -0
  21. package/templates/nextjs-saas/components/analytics.tsx +20 -0
  22. package/templates/nextjs-saas/components/auth-form.tsx +144 -0
  23. package/templates/nextjs-saas/components/auth-page.tsx +30 -0
  24. package/templates/nextjs-saas/components/chat-box.tsx +95 -0
  25. package/templates/nextjs-saas/components/header.tsx +24 -0
  26. package/templates/nextjs-saas/components/not-configured.tsx +17 -0
  27. package/templates/nextjs-saas/components/replay-consent.tsx +75 -0
  28. package/templates/nextjs-saas/components/replay-identify.tsx +21 -0
  29. package/templates/nextjs-saas/components/sign-out-button.tsx +25 -0
  30. package/templates/nextjs-saas/components/ui.tsx +47 -0
  31. package/templates/nextjs-saas/db/migrate.ts +21 -0
  32. package/templates/nextjs-saas/db/schema.ts +18 -0
  33. package/templates/nextjs-saas/lib/auth/client.ts +6 -0
  34. package/templates/nextjs-saas/lib/auth/server.ts +44 -0
  35. package/templates/nextjs-saas/lib/db.ts +32 -0
  36. package/templates/nextjs-saas/lib/env.ts +36 -0
  37. package/templates/nextjs-saas/lib/replay.ts +55 -0
  38. package/templates/nextjs-saas/next.config.ts +9 -0
  39. package/templates/nextjs-saas/package-lock.json +5359 -0
  40. package/templates/nextjs-saas/package.json +32 -0
  41. package/templates/nextjs-saas/postcss.config.mjs +3 -0
  42. package/templates/nextjs-saas/proxy.ts +18 -0
  43. package/templates/nextjs-saas/tsconfig.json +21 -0
@@ -0,0 +1,144 @@
1
+ 'use client';
2
+
3
+ import Link from 'next/link';
4
+ import { useRouter } from 'next/navigation';
5
+ import { type FormEvent, useState } from 'react';
6
+ import { authClient } from '@/lib/auth/client';
7
+ import { Button, Input } from '@/components/ui';
8
+
9
+ type Mode = 'sign-in' | 'sign-up';
10
+
11
+ const AFTER_SIGN_IN = '/dashboard';
12
+
13
+ /**
14
+ * Email/password and Google. Runs in the browser so requests reach
15
+ * `/api/auth` with the page's own Origin header, which the sign-in service
16
+ * checks.
17
+ */
18
+ export function AuthForm({ mode }: { mode: Mode }) {
19
+ const router = useRouter();
20
+ const [pending, setPending] = useState<'email' | 'google' | null>(null);
21
+ const [error, setError] = useState<string | null>(null);
22
+
23
+ async function onSubmit(e: FormEvent<HTMLFormElement>) {
24
+ e.preventDefault();
25
+ setError(null);
26
+ setPending('email');
27
+ const form = new FormData(e.currentTarget);
28
+ const email = String(form.get('email') ?? '').trim();
29
+ const password = String(form.get('password') ?? '');
30
+ const name = String(form.get('name') ?? '').trim() || email.split('@')[0] || email;
31
+
32
+ const { error } =
33
+ mode === 'sign-up'
34
+ ? await authClient.signUp.email({ name, email, password })
35
+ : await authClient.signIn.email({ email, password });
36
+ if (error) {
37
+ setError(error.message ?? 'Something went wrong. Please try again.');
38
+ setPending(null);
39
+ return;
40
+ }
41
+ router.push(AFTER_SIGN_IN);
42
+ router.refresh();
43
+ }
44
+
45
+ async function onGoogle() {
46
+ setError(null);
47
+ setPending('google');
48
+ const { error } = await authClient.signIn.social({
49
+ provider: 'google',
50
+ callbackURL: AFTER_SIGN_IN,
51
+ });
52
+ // On success the browser is already on its way to Google.
53
+ if (error) {
54
+ setError(error.message ?? 'Google sign-in failed. Please try again.');
55
+ setPending(null);
56
+ }
57
+ }
58
+
59
+ const isSignUp = mode === 'sign-up';
60
+
61
+ return (
62
+ <div className="w-full max-w-sm">
63
+ <h1 className="text-2xl font-semibold tracking-tight">
64
+ {isSignUp ? 'Create your account' : 'Welcome back'}
65
+ </h1>
66
+ <p className="mt-1 text-sm text-zinc-600">
67
+ {isSignUp ? 'It takes less than a minute.' : 'Sign in to continue.'}
68
+ </p>
69
+
70
+ <Button
71
+ type="button"
72
+ variant="secondary"
73
+ className="mt-8 w-full"
74
+ onClick={onGoogle}
75
+ disabled={pending !== null}
76
+ >
77
+ <GoogleIcon />
78
+ {pending === 'google' ? 'Redirecting…' : 'Continue with Google'}
79
+ </Button>
80
+
81
+ <div className="my-6 flex items-center gap-3 text-xs text-zinc-400">
82
+ <span className="h-px flex-1 bg-zinc-200" />
83
+ or
84
+ <span className="h-px flex-1 bg-zinc-200" />
85
+ </div>
86
+
87
+ <form onSubmit={onSubmit} className="grid gap-3">
88
+ {error ? (
89
+ <p role="alert" className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
90
+ {error}
91
+ </p>
92
+ ) : null}
93
+ {isSignUp ? (
94
+ <label className="grid gap-1.5 text-sm font-medium">
95
+ Name
96
+ <Input name="name" autoComplete="name" placeholder="Ada Lovelace" />
97
+ </label>
98
+ ) : null}
99
+ <label className="grid gap-1.5 text-sm font-medium">
100
+ Email
101
+ <Input name="email" type="email" required autoComplete="email" placeholder="you@example.com" />
102
+ </label>
103
+ <label className="grid gap-1.5 text-sm font-medium">
104
+ Password
105
+ <Input
106
+ name="password"
107
+ type="password"
108
+ required
109
+ minLength={8}
110
+ autoComplete={isSignUp ? 'new-password' : 'current-password'}
111
+ placeholder={isSignUp ? 'At least 8 characters' : ''}
112
+ />
113
+ </label>
114
+ <Button type="submit" className="mt-2 w-full" disabled={pending !== null}>
115
+ {pending === 'email'
116
+ ? isSignUp
117
+ ? 'Creating account…'
118
+ : 'Signing in…'
119
+ : isSignUp
120
+ ? 'Create account'
121
+ : 'Sign in'}
122
+ </Button>
123
+ </form>
124
+
125
+ <p className="mt-6 text-center text-sm text-zinc-600">
126
+ {isSignUp ? 'Already have an account? ' : 'New here? '}
127
+ <Link href={isSignUp ? '/sign-in' : '/sign-up'} className="font-medium text-brand hover:underline">
128
+ {isSignUp ? 'Sign in' : 'Create an account'}
129
+ </Link>
130
+ </p>
131
+ </div>
132
+ );
133
+ }
134
+
135
+ function GoogleIcon() {
136
+ return (
137
+ <svg viewBox="0 0 24 24" className="size-4" aria-hidden="true">
138
+ <path fill="#4285F4" d="M23.5 12.3c0-.8-.1-1.6-.2-2.3H12v4.5h6.5a5.6 5.6 0 0 1-2.4 3.6v3h3.9c2.2-2.1 3.5-5.1 3.5-8.8z" />
139
+ <path fill="#34A853" d="M12 24c3.2 0 6-1.1 7.9-2.9l-3.9-3c-1.1.7-2.4 1.2-4 1.2-3.1 0-5.7-2.1-6.6-4.9h-4v3.1A12 12 0 0 0 12 24z" />
140
+ <path fill="#FBBC05" d="M5.4 14.4a7.2 7.2 0 0 1 0-4.7V6.6h-4a12 12 0 0 0 0 10.8l4-3z" />
141
+ <path fill="#EA4335" d="M12 4.8c1.8 0 3.3.6 4.6 1.8l3.4-3.4A12 12 0 0 0 1.4 6.6l4 3.1C6.3 6.9 8.9 4.8 12 4.8z" />
142
+ </svg>
143
+ );
144
+ }
@@ -0,0 +1,30 @@
1
+ import { redirect } from 'next/navigation';
2
+ import { connection } from 'next/server';
3
+ import { AuthForm } from '@/components/auth-form';
4
+ import { Logo } from '@/components/header';
5
+ import { NotConfigured } from '@/components/not-configured';
6
+ import { currentUser } from '@/lib/auth/server';
7
+ import { missingAuthEnv } from '@/lib/env';
8
+
9
+ export async function AuthPage({ mode }: { mode: 'sign-in' | 'sign-up' }) {
10
+ await connection(); // read the env per request, never at build time
11
+ const configured = missingAuthEnv().length === 0;
12
+ if (configured && (await currentUser())) redirect('/dashboard');
13
+
14
+ return (
15
+ <main className="flex flex-1 flex-col">
16
+ <div className="px-6 py-5">
17
+ <Logo />
18
+ </div>
19
+ <div className="flex flex-1 items-center justify-center px-6 pb-24">
20
+ {configured ? (
21
+ <AuthForm mode={mode} />
22
+ ) : (
23
+ <div className="w-full max-w-sm">
24
+ <NotConfigured what="Sign-in" />
25
+ </div>
26
+ )}
27
+ </div>
28
+ </main>
29
+ );
30
+ }
@@ -0,0 +1,95 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useRef, useState } from 'react';
4
+ import { Button, Input } from '@/components/ui';
5
+
6
+ type Message = { role: 'user' | 'assistant'; content: string };
7
+
8
+ /** A small chat box that streams answers from `/api/chat` as they are written. */
9
+ export function ChatBox() {
10
+ const [messages, setMessages] = useState<Message[]>([]);
11
+ const [input, setInput] = useState('');
12
+ const [busy, setBusy] = useState(false);
13
+ const [error, setError] = useState<string | null>(null);
14
+ const abort = useRef<AbortController | null>(null);
15
+
16
+ useEffect(() => () => abort.current?.abort(), []);
17
+
18
+ async function send(e: React.FormEvent) {
19
+ e.preventDefault();
20
+ const text = input.trim();
21
+ if (!text || busy) return;
22
+ const history: Message[] = [...messages, { role: 'user', content: text }];
23
+ setMessages([...history, { role: 'assistant', content: '' }]);
24
+ setInput('');
25
+ setError(null);
26
+ setBusy(true);
27
+ abort.current = new AbortController();
28
+ try {
29
+ const res = await fetch('/api/chat', {
30
+ method: 'POST',
31
+ headers: { 'content-type': 'application/json' },
32
+ body: JSON.stringify({ messages: history }),
33
+ signal: abort.current.signal,
34
+ });
35
+ if (!res.ok || !res.body) {
36
+ const body = (await res.json().catch(() => null)) as { error?: string } | null;
37
+ throw new Error(body?.error ?? 'Something went wrong.');
38
+ }
39
+ const reader = res.body.getReader();
40
+ const decoder = new TextDecoder();
41
+ for (;;) {
42
+ const { done, value } = await reader.read();
43
+ if (done) break;
44
+ const piece = decoder.decode(value, { stream: true });
45
+ setMessages((m) => {
46
+ const last = m[m.length - 1];
47
+ return last ? [...m.slice(0, -1), { ...last, content: last.content + piece }] : m;
48
+ });
49
+ }
50
+ } catch (err) {
51
+ if ((err as Error).name !== 'AbortError') setError((err as Error).message);
52
+ } finally {
53
+ setBusy(false);
54
+ }
55
+ }
56
+
57
+ return (
58
+ <div className="rounded-xl border border-zinc-200">
59
+ <ul className="max-h-80 space-y-3 overflow-y-auto p-4 text-sm" aria-live="polite">
60
+ {messages.length === 0 ? (
61
+ <li className="text-zinc-500">Ask anything. Answers stream in as they are written.</li>
62
+ ) : (
63
+ messages.map((m, i) => (
64
+ <li key={i} className={m.role === 'user' ? 'text-right' : ''}>
65
+ <span
66
+ className={`inline-block max-w-[85%] whitespace-pre-wrap rounded-lg px-3 py-2 text-left ${
67
+ m.role === 'user' ? 'bg-brand text-brand-foreground' : 'bg-zinc-100 text-zinc-900'
68
+ }`}
69
+ >
70
+ {m.content || '…'}
71
+ </span>
72
+ </li>
73
+ ))
74
+ )}
75
+ </ul>
76
+ {error ? <p className="px-4 pb-2 text-sm text-red-600">{error}</p> : null}
77
+ <form onSubmit={send} className="flex gap-2 border-t border-zinc-200 p-3">
78
+ <Input
79
+ value={input}
80
+ onChange={(e) => setInput(e.target.value)}
81
+ placeholder="Ask the assistant…"
82
+ aria-label="Message"
83
+ maxLength={4000}
84
+ />
85
+ {busy ? (
86
+ <Button type="button" variant="secondary" onClick={() => abort.current?.abort()}>
87
+ Stop
88
+ </Button>
89
+ ) : (
90
+ <Button type="submit">Send</Button>
91
+ )}
92
+ </form>
93
+ </div>
94
+ );
95
+ }
@@ -0,0 +1,24 @@
1
+ import Link from 'next/link';
2
+ import { site } from '@/app.config';
3
+
4
+ export function Logo() {
5
+ return (
6
+ <Link href="/" className="flex items-center gap-2 font-semibold tracking-tight">
7
+ <span className="grid size-7 place-items-center rounded-md bg-brand text-sm text-brand-foreground">
8
+ {site.name.slice(0, 1).toUpperCase()}
9
+ </span>
10
+ {site.name}
11
+ </Link>
12
+ );
13
+ }
14
+
15
+ export function Header({ children }: { children?: React.ReactNode }) {
16
+ return (
17
+ <header className="border-b border-zinc-200">
18
+ <div className="mx-auto flex h-16 max-w-5xl items-center justify-between px-6">
19
+ <Logo />
20
+ <nav className="flex items-center gap-3 text-sm">{children}</nav>
21
+ </div>
22
+ </header>
23
+ );
24
+ }
@@ -0,0 +1,17 @@
1
+ import { Notice } from '@/components/ui';
2
+
3
+ /**
4
+ * Shown instead of a feature whose environment is missing. The variable names
5
+ * go to the server log (see lib/env.ts), not to the page.
6
+ */
7
+ export function NotConfigured({ what }: { what: string }) {
8
+ return (
9
+ <Notice title={`${what} isn't set up yet`}>
10
+ <p>
11
+ Deployed apps are set up automatically. For local development, run{' '}
12
+ <code className="font-mono">vibiz dev</code> in this folder and restart
13
+ the dev server.
14
+ </p>
15
+ </Notice>
16
+ );
17
+ }
@@ -0,0 +1,75 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useState } from 'react';
4
+ import { Button } from '@/components/ui';
5
+ import { type Consent, readConsent, replay, replayEnabled, writeConsent } from '@/lib/replay';
6
+
7
+ /**
8
+ * Asks once whether this visit may be recorded to find and fix problems.
9
+ * Typed text is hidden in the recording either way. Until the visitor says
10
+ * yes, nothing is recorded and nothing is stored for replay.
11
+ */
12
+ export function ReplayConsent() {
13
+ const [consent, setConsent] = useState<Consent | 'unknown'>('unknown');
14
+
15
+ useEffect(() => {
16
+ if (!replayEnabled()) return;
17
+ const current = readConsent();
18
+ setConsent(current);
19
+ if (current === 'granted') replay('start');
20
+ const reopen = () => setConsent(readConsent());
21
+ window.addEventListener('vibiz-replay-consent', reopen);
22
+ return () => window.removeEventListener('vibiz-replay-consent', reopen);
23
+ }, []);
24
+
25
+ if (consent !== null) return null;
26
+
27
+ const choose = (value: 'granted' | 'denied') => {
28
+ writeConsent(value);
29
+ setConsent(value);
30
+ replay(value === 'granted' ? 'start' : 'stop');
31
+ };
32
+
33
+ return (
34
+ <div
35
+ role="dialog"
36
+ aria-live="polite"
37
+ aria-label="Recording preferences"
38
+ className="fixed inset-x-4 bottom-4 z-50 mx-auto max-w-md rounded-xl border border-zinc-200 bg-white p-4 shadow-lg sm:right-4 sm:left-auto sm:mx-0"
39
+ >
40
+ <p className="text-sm text-zinc-700">
41
+ May we record how you use this site to find and fix problems? What you type stays hidden.
42
+ </p>
43
+ <div className="mt-3 flex justify-end gap-2">
44
+ <Button variant="ghost" className="h-9" onClick={() => choose('denied')}>
45
+ No thanks
46
+ </Button>
47
+ <Button className="h-9" data-vibiz-name="replay-consent-allow" onClick={() => choose('granted')}>
48
+ Allow
49
+ </Button>
50
+ </div>
51
+ </div>
52
+ );
53
+ }
54
+
55
+ /** Lets a visitor change their mind: shows the question again. */
56
+ export function ReplayChoicesLink() {
57
+ const [shown, setShown] = useState(false);
58
+ useEffect(() => setShown(replayEnabled()), []);
59
+ if (!shown) return null;
60
+ return (
61
+ <button
62
+ type="button"
63
+ className="underline-offset-4 hover:underline"
64
+ onClick={() => {
65
+ replay('stop');
66
+ try {
67
+ window.localStorage.removeItem('vibiz-replay-consent');
68
+ } catch {}
69
+ window.dispatchEvent(new Event('vibiz-replay-consent'));
70
+ }}
71
+ >
72
+ Recording choices
73
+ </button>
74
+ );
75
+ }
@@ -0,0 +1,21 @@
1
+ 'use client';
2
+
3
+ import { useEffect } from 'react';
4
+ import { readConsent, replay, replayEnabled } from '@/lib/replay';
5
+
6
+ /**
7
+ * Signed-in customers' sessions are always worth keeping: record the whole
8
+ * tab, not just the minutes before an error. Still only with consent.
9
+ */
10
+ export function ReplaySignedIn() {
11
+ useEffect(() => {
12
+ if (!replayEnabled()) return;
13
+ const apply = () => {
14
+ if (readConsent() === 'granted') replay('always');
15
+ };
16
+ apply();
17
+ window.addEventListener('vibiz-replay-consent', apply);
18
+ return () => window.removeEventListener('vibiz-replay-consent', apply);
19
+ }, []);
20
+ return null;
21
+ }
@@ -0,0 +1,25 @@
1
+ 'use client';
2
+
3
+ import { useRouter } from 'next/navigation';
4
+ import { useState } from 'react';
5
+ import { authClient } from '@/lib/auth/client';
6
+ import { Button } from '@/components/ui';
7
+
8
+ export function SignOutButton() {
9
+ const router = useRouter();
10
+ const [pending, setPending] = useState(false);
11
+ return (
12
+ <Button
13
+ variant="ghost"
14
+ disabled={pending}
15
+ onClick={async () => {
16
+ setPending(true);
17
+ await authClient.signOut();
18
+ router.push('/');
19
+ router.refresh();
20
+ }}
21
+ >
22
+ {pending ? 'Signing out…' : 'Sign out'}
23
+ </Button>
24
+ );
25
+ }
@@ -0,0 +1,47 @@
1
+ import Link from 'next/link';
2
+ import type { ComponentProps } from 'react';
3
+
4
+ const base =
5
+ 'inline-flex h-10 items-center justify-center gap-2 rounded-lg px-4 text-sm font-medium transition disabled:cursor-not-allowed disabled:opacity-60';
6
+
7
+ export const buttonStyles = {
8
+ primary: `${base} bg-brand text-brand-foreground hover:opacity-90`,
9
+ secondary: `${base} border border-zinc-300 bg-white text-zinc-900 hover:bg-zinc-50`,
10
+ ghost: `${base} text-zinc-600 hover:bg-zinc-100 hover:text-zinc-900`,
11
+ };
12
+
13
+ type Variant = keyof typeof buttonStyles;
14
+
15
+ export function Button({
16
+ variant = 'primary',
17
+ className = '',
18
+ ...props
19
+ }: ComponentProps<'button'> & { variant?: Variant }) {
20
+ return <button className={`${buttonStyles[variant]} ${className}`} {...props} />;
21
+ }
22
+
23
+ export function ButtonLink({
24
+ variant = 'primary',
25
+ className = '',
26
+ ...props
27
+ }: ComponentProps<typeof Link> & { variant?: Variant }) {
28
+ return <Link className={`${buttonStyles[variant]} ${className}`} {...props} />;
29
+ }
30
+
31
+ export function Input(props: ComponentProps<'input'>) {
32
+ return (
33
+ <input
34
+ className="h-10 w-full rounded-lg border border-zinc-300 bg-white px-3 text-sm outline-none transition placeholder:text-zinc-400 focus:border-brand focus:ring-2 focus:ring-brand/20"
35
+ {...props}
36
+ />
37
+ );
38
+ }
39
+
40
+ export function Notice({ title, children }: { title: string; children: React.ReactNode }) {
41
+ return (
42
+ <div role="alert" className="rounded-xl border border-amber-200 bg-amber-50 p-5 text-sm text-amber-900">
43
+ <p className="font-medium">{title}</p>
44
+ <div className="mt-1 text-amber-800">{children}</div>
45
+ </div>
46
+ );
47
+ }
@@ -0,0 +1,21 @@
1
+ import { sql } from 'drizzle-orm';
2
+ import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
3
+
4
+ /**
5
+ * Create-if-missing, run once per server process before the first query.
6
+ * Every statement is idempotent, so concurrent cold starts are safe. Keep it
7
+ * in step with `db/schema.ts`; add new statements at the end.
8
+ */
9
+ const STATEMENTS = [
10
+ sql`CREATE TABLE IF NOT EXISTS notes (
11
+ id serial PRIMARY KEY,
12
+ user_id text NOT NULL,
13
+ body text NOT NULL,
14
+ created_at timestamptz NOT NULL DEFAULT now()
15
+ )`,
16
+ sql`CREATE INDEX IF NOT EXISTS notes_user_id_idx ON notes (user_id)`,
17
+ ];
18
+
19
+ export async function migrate(db: NodePgDatabase<Record<string, unknown>>): Promise<void> {
20
+ for (const statement of STATEMENTS) await db.execute(statement);
21
+ }
@@ -0,0 +1,18 @@
1
+ import { index, pgTable, serial, text, timestamp } from 'drizzle-orm/pg-core';
2
+
3
+ /**
4
+ * Your tables. Users live in the sign-in service's own schema, so reference
5
+ * them by id (`text`) rather than with a foreign key.
6
+ */
7
+ export const notes = pgTable(
8
+ 'notes',
9
+ {
10
+ id: serial('id').primaryKey(),
11
+ userId: text('user_id').notNull(),
12
+ body: text('body').notNull(),
13
+ createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
14
+ },
15
+ (t) => [index('notes_user_id_idx').on(t.userId)],
16
+ );
17
+
18
+ export type Note = typeof notes.$inferSelect;
@@ -0,0 +1,6 @@
1
+ 'use client';
2
+
3
+ import { createAuthClient } from '@neondatabase/auth/next';
4
+
5
+ /** Browser-side auth: talks to this app's own `/api/auth/*` route. */
6
+ export const authClient = createAuthClient();
@@ -0,0 +1,44 @@
1
+ import { createNeonAuth } from '@neondatabase/auth/next/server';
2
+ import { missingAuthEnv } from '@/lib/env';
3
+
4
+ type Auth = ReturnType<typeof createNeonAuth>;
5
+
6
+ let cached: Auth | null = null;
7
+
8
+ /** Thrown when sign-in is used before its environment is set. */
9
+ export class AuthNotConfiguredError extends Error {
10
+ constructor(readonly missing: string[]) {
11
+ super('Sign-in is not set up yet. Run `vibiz dev` for local development.');
12
+ this.name = 'AuthNotConfiguredError';
13
+ }
14
+ }
15
+
16
+ /**
17
+ * The server-side auth handle. Built lazily: constructing it validates the
18
+ * cookie secret, and doing that at import time would take down every page
19
+ * when the env is missing.
20
+ */
21
+ export function getAuth(): Auth {
22
+ if (cached) return cached;
23
+ const missing = missingAuthEnv();
24
+ if (missing.length) throw new AuthNotConfiguredError(missing);
25
+ cached = createNeonAuth({
26
+ baseUrl: process.env.VIBIZ_AUTH_BASE_URL as string,
27
+ cookies: { secret: process.env.VIBIZ_AUTH_COOKIE_SECRET as string },
28
+ });
29
+ return cached;
30
+ }
31
+
32
+ export interface SessionUser {
33
+ id: string;
34
+ name: string;
35
+ email: string;
36
+ image?: string | null;
37
+ }
38
+
39
+ /** The signed-in user, or null (also null when sign-in is not configured). */
40
+ export async function currentUser(): Promise<SessionUser | null> {
41
+ if (missingAuthEnv().length) return null;
42
+ const { data } = await getAuth().getSession();
43
+ return (data?.user as SessionUser | undefined) ?? null;
44
+ }
@@ -0,0 +1,32 @@
1
+ import { drizzle, type NodePgDatabase } from 'drizzle-orm/node-postgres';
2
+ import { Pool } from 'pg';
3
+ import { migrate } from '@/db/migrate';
4
+ import * as schema from '@/db/schema';
5
+ import { missingDatabaseEnv } from '@/lib/env';
6
+
7
+ type Db = NodePgDatabase<typeof schema>;
8
+
9
+ let ready: Promise<Db> | null = null;
10
+
11
+ export class DatabaseNotConfiguredError extends Error {
12
+ constructor() {
13
+ super('The database is not set up yet. Run `vibiz dev` for local development.');
14
+ this.name = 'DatabaseNotConfiguredError';
15
+ }
16
+ }
17
+
18
+ /** The database, with its tables created on first use. */
19
+ export function getDb(): Promise<Db> {
20
+ if (missingDatabaseEnv().length) throw new DatabaseNotConfiguredError();
21
+ ready ??= (async () => {
22
+ // A small pool: serverless functions run many instances, each with its own.
23
+ const pool = new Pool({ connectionString: process.env.DATABASE_URL, max: 3 });
24
+ const db = drizzle(pool, { schema });
25
+ await migrate(db);
26
+ return db;
27
+ })().catch((err) => {
28
+ ready = null; // let the next request retry (a sleeping database, a blip)
29
+ throw err;
30
+ });
31
+ return ready;
32
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * What the app needs from its environment, checked at request time so a
3
+ * missing value shows a clear message instead of crashing the page. Missing
4
+ * names are logged once on the server; pages only say what isn't set up.
5
+ */
6
+
7
+ const warned = new Set<string>();
8
+
9
+ function report(feature: string, missing: string[]): string[] {
10
+ if (missing.length && !warned.has(feature)) {
11
+ warned.add(feature);
12
+ console.warn(
13
+ `[env] ${feature} is not configured: missing ${missing.join(', ')}. ` +
14
+ 'Run `vibiz dev` and restart the dev server.',
15
+ );
16
+ }
17
+ return missing;
18
+ }
19
+
20
+ export function missingAuthEnv(): string[] {
21
+ const missing: string[] = [];
22
+ if (!process.env.VIBIZ_AUTH_BASE_URL) missing.push('VIBIZ_AUTH_BASE_URL');
23
+ const secret = process.env.VIBIZ_AUTH_COOKIE_SECRET;
24
+ if (!secret || secret.length < 32) missing.push('VIBIZ_AUTH_COOKIE_SECRET (32+ chars)');
25
+ return report('sign-in', missing);
26
+ }
27
+
28
+ export function missingDatabaseEnv(): string[] {
29
+ return report('database', process.env.DATABASE_URL ? [] : ['DATABASE_URL']);
30
+ }
31
+
32
+ /** The AI gateway: set by the `ai` resource (`vibiz add ai`). */
33
+ export function missingAiEnv(): string[] {
34
+ const missing = ['VIBIZ_AI_BASE_URL', 'VIBIZ_AI_API_KEY'].filter((k) => !process.env[k]);
35
+ return report('the AI assistant', missing);
36
+ }