@flaredev/cli 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 (97) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +47 -0
  3. package/bin/flare.js +2 -0
  4. package/dist/index.js +3664 -0
  5. package/package.json +56 -0
  6. package/templates/app/.dev.vars.example +13 -0
  7. package/templates/app/README.md +9 -0
  8. package/templates/app/_gitignore +10 -0
  9. package/templates/app/app/admin/actions.ts +113 -0
  10. package/templates/app/app/admin/layout.tsx +32 -0
  11. package/templates/app/app/admin/page.tsx +63 -0
  12. package/templates/app/app/api/auth/[...all]/route.ts +4 -0
  13. package/templates/app/app/api/health/route.ts +7 -0
  14. package/templates/app/app/api/storage/route.ts +6 -0
  15. package/templates/app/app/dashboard/page.tsx +18 -0
  16. package/templates/app/app/globals.css +156 -0
  17. package/templates/app/app/layout.tsx +26 -0
  18. package/templates/app/app/page.tsx +19 -0
  19. package/templates/app/app/sign-in/page.tsx +33 -0
  20. package/templates/app/app/sign-up/page.tsx +23 -0
  21. package/templates/app/components/admin/admin-header.tsx +78 -0
  22. package/templates/app/components/admin/fields/date-field.tsx +112 -0
  23. package/templates/app/components/admin/fields/field-widget.tsx +128 -0
  24. package/templates/app/components/admin/fields/file-field.tsx +302 -0
  25. package/templates/app/components/admin/fields/relation-field.tsx +132 -0
  26. package/templates/app/components/admin/query.ts +25 -0
  27. package/templates/app/components/admin/resource-form-page.tsx +55 -0
  28. package/templates/app/components/admin/resource-form.tsx +139 -0
  29. package/templates/app/components/admin/resource-icon.tsx +85 -0
  30. package/templates/app/components/admin/resource-nav.tsx +109 -0
  31. package/templates/app/components/admin/resource-table-toolbar.tsx +95 -0
  32. package/templates/app/components/admin/resource-table.tsx +251 -0
  33. package/templates/app/components/admin/row-actions.tsx +111 -0
  34. package/templates/app/components/auth-form.tsx +120 -0
  35. package/templates/app/components/sign-out-button.tsx +18 -0
  36. package/templates/app/components/ui/alert-dialog.tsx +196 -0
  37. package/templates/app/components/ui/alert.tsx +65 -0
  38. package/templates/app/components/ui/avatar.tsx +108 -0
  39. package/templates/app/components/ui/badge.tsx +53 -0
  40. package/templates/app/components/ui/breadcrumb.tsx +110 -0
  41. package/templates/app/components/ui/button.tsx +65 -0
  42. package/templates/app/components/ui/calendar.tsx +220 -0
  43. package/templates/app/components/ui/card.tsx +92 -0
  44. package/templates/app/components/ui/command.tsx +184 -0
  45. package/templates/app/components/ui/dialog.tsx +158 -0
  46. package/templates/app/components/ui/dropdown-menu.tsx +256 -0
  47. package/templates/app/components/ui/empty.tsx +103 -0
  48. package/templates/app/components/ui/field.tsx +248 -0
  49. package/templates/app/components/ui/input-group.tsx +170 -0
  50. package/templates/app/components/ui/input.tsx +20 -0
  51. package/templates/app/components/ui/label.tsx +23 -0
  52. package/templates/app/components/ui/pagination.tsx +127 -0
  53. package/templates/app/components/ui/popover.tsx +88 -0
  54. package/templates/app/components/ui/progress.tsx +30 -0
  55. package/templates/app/components/ui/select.tsx +189 -0
  56. package/templates/app/components/ui/separator.tsx +27 -0
  57. package/templates/app/components/ui/sheet.tsx +142 -0
  58. package/templates/app/components/ui/sidebar.tsx +726 -0
  59. package/templates/app/components/ui/skeleton.tsx +13 -0
  60. package/templates/app/components/ui/sonner.tsx +38 -0
  61. package/templates/app/components/ui/spinner.tsx +15 -0
  62. package/templates/app/components/ui/switch.tsx +34 -0
  63. package/templates/app/components/ui/table.tsx +115 -0
  64. package/templates/app/components/ui/textarea.tsx +17 -0
  65. package/templates/app/components/ui/tooltip.tsx +56 -0
  66. package/templates/app/components.json +21 -0
  67. package/templates/app/db/auth-schema.ts +118 -0
  68. package/templates/app/db/flare-schema.ts +15 -0
  69. package/templates/app/db/index.ts +10 -0
  70. package/templates/app/db/schema.ts +12 -0
  71. package/templates/app/drizzle.config.ts +8 -0
  72. package/templates/app/hooks/use-mobile.ts +19 -0
  73. package/templates/app/lib/admin-nav.ts +15 -0
  74. package/templates/app/lib/admin.ts +103 -0
  75. package/templates/app/lib/api.ts +24 -0
  76. package/templates/app/lib/auth-client.ts +4 -0
  77. package/templates/app/lib/auth.ts +44 -0
  78. package/templates/app/lib/cache.ts +83 -0
  79. package/templates/app/lib/mail.ts +36 -0
  80. package/templates/app/lib/realtime.ts +45 -0
  81. package/templates/app/lib/security.ts +13 -0
  82. package/templates/app/lib/session.ts +24 -0
  83. package/templates/app/lib/storage.ts +26 -0
  84. package/templates/app/lib/utils.ts +6 -0
  85. package/templates/app/migrations/0000_init.sql +66 -0
  86. package/templates/app/migrations/meta/0000_snapshot.json +438 -0
  87. package/templates/app/migrations/meta/_journal.json +13 -0
  88. package/templates/app/next.config.ts +5 -0
  89. package/templates/app/policies/index.ts +4 -0
  90. package/templates/app/postcss.config.mjs +7 -0
  91. package/templates/app/proxy.ts +20 -0
  92. package/templates/app/resources/index.ts +4 -0
  93. package/templates/app/resources/server.ts +6 -0
  94. package/templates/app/tsconfig.json +22 -0
  95. package/templates/app/vite.config.ts +27 -0
  96. package/templates/app/worker/index.ts +40 -0
  97. package/templates/app/wrangler.jsonc +52 -0
@@ -0,0 +1,44 @@
1
+ import { env, waitUntil } from "cloudflare:workers";
2
+ import { betterAuth } from "better-auth";
3
+ import { drizzleAdapter } from "@better-auth/drizzle-adapter";
4
+ import { nextCookies } from "better-auth/next-js";
5
+ import { admin } from "better-auth/plugins/admin";
6
+ import { hashPassword, verifyPassword } from "@flaredev/core";
7
+ import { getDb, schema } from "@/db";
8
+
9
+ /** Optional: set when serving from a custom domain (e.g. https://example.com). */
10
+ const configuredURL = (env as { BETTER_AUTH_URL?: string }).BETTER_AUTH_URL;
11
+
12
+ export const auth = betterAuth({
13
+ appName: "__APP_NAME__",
14
+ secret: env.BETTER_AUTH_SECRET,
15
+ // Without BETTER_AUTH_URL, accept local dev on any port and this app's workers.dev
16
+ // host. Cloudflare only routes hosts this Worker serves, so the allowlist can't be spoofed.
17
+ baseURL: configuredURL || {
18
+ allowedHosts: ["localhost:*", "127.0.0.1:*", "__APP_NAME__.*.workers.dev"],
19
+ },
20
+ database: drizzleAdapter(getDb(), { provider: "sqlite", schema }),
21
+ socialProviders: __SOCIAL_PROVIDERS__,
22
+ emailAndPassword: {
23
+ enabled: true,
24
+ // PBKDF2 via WebCrypto: the default scrypt exceeds Workers CPU limits.
25
+ password: { hash: hashPassword, verify: verifyPassword },
26
+ },
27
+ advanced: {
28
+ // Let emails and other deferred work finish after the response is sent.
29
+ backgroundTasks: { handler: waitUntil },
30
+ },
31
+ plugins: [
32
+ // Adds user.role (default "user") and admin APIs. The /admin area allows ADMIN_ROLES (lib/admin.ts).
33
+ admin({ defaultRole: "user", adminRoles: ["admin"] }),
34
+ // Must stay last: lets server actions set auth cookies.
35
+ nextCookies(),
36
+ ],
37
+ });
38
+
39
+ export type Session = typeof auth.$Infer.Session;
40
+
41
+ /** OAuth providers with credentials configured, e.g. ["github"]. */
42
+ export function enabledSocialProviders(): string[] {
43
+ return Object.keys(auth.options.socialProviders ?? {});
44
+ }
@@ -0,0 +1,83 @@
1
+ import { revalidateTag, unstable_cache } from "next/cache";
2
+ import type { ChangeEvent } from "@flaredev/core/server";
3
+
4
+ /**
5
+ * Caching for data you read far more often than you write.
6
+ *
7
+ * Only the **data cache** (Workers KV) is configured in `vite.config.ts`. It is
8
+ * what `cached()` below, `fetch()` and `"use cache"` write to. Shared by every
9
+ * colo and every isolate, so a value computed once is reused until it expires or
10
+ * its tag is revalidated.
11
+ *
12
+ * vinext's page-level CDN cache (the Workers Cache `cdn` adapter) is off until a
13
+ * vinext fix lands: in the current beta, pages that redirect break behind it.
14
+ *
15
+ * Nothing here caches per-user data: everything keyed by session belongs
16
+ * outside the cache, since these entries are shared between visitors.
17
+ */
18
+
19
+ /** How long cached values stay fresh, in seconds. Pick the one that matches how stale you can stand. */
20
+ export const TTL = {
21
+ /** Changes often, cheap to recompute: counts, dashboards. */
22
+ short: 60,
23
+ /** The usual case: lists and detail views behind a resource tag. */
24
+ medium: 5 * 60,
25
+ /** Rarely changes: settings, published content, reference tables. */
26
+ long: 60 * 60,
27
+ } as const;
28
+
29
+ /** Tag for everything derived from a resource, e.g. `resource/Deal`. */
30
+ export const resourceTag = (resource: string) => `resource/${resource}`;
31
+
32
+ /** Tag for one record, e.g. `resource/Deal/0f9…`. */
33
+ export const recordTag = (resource: string, id: string) => `resource/${resource}/${id}`;
34
+
35
+ /**
36
+ * The KV data cache silently ignores tags containing `:`, `\` or control
37
+ * characters — the entry is stored untagged, so no revalidation ever reaches
38
+ * it. Refuse them here instead, where the mistake is still visible.
39
+ */
40
+ function checkTag(tag: string): string {
41
+ if (!tag || tag.length > 256 || /[\x00-\x1f\\:]/.test(tag)) {
42
+ throw new Error(`Cache tag "${tag}" can't be used: tags must be 1–256 characters without ":", "\\" or control characters.`);
43
+ }
44
+ return tag;
45
+ }
46
+
47
+ /**
48
+ * Cache the result of an async function in the data cache.
49
+ *
50
+ * `keyParts` identify the value (include every argument the result depends on);
51
+ * `tags` say what invalidates it. Cached functions must not read cookies,
52
+ * headers, or anything else request-specific — the value is shared.
53
+ *
54
+ * ```ts
55
+ * const openDeals = cached(
56
+ * () => getDb().select({ total: count() }).from(deals).where(eq(deals.stage, "open")),
57
+ * ["deals", "open-count"],
58
+ * { tags: [resourceTag("Deal")] },
59
+ * );
60
+ * ```
61
+ */
62
+ export function cached<Args extends unknown[], Value>(
63
+ fn: (...args: Args) => Promise<Value>,
64
+ keyParts: string[],
65
+ options: { tags?: string[]; revalidate?: number } = {},
66
+ ) {
67
+ return unstable_cache(fn, keyParts, {
68
+ revalidate: options.revalidate ?? TTL.medium,
69
+ tags: (options.tags ?? []).map(checkTag),
70
+ });
71
+ }
72
+
73
+ /**
74
+ * Drop everything cached for a resource (and for the record that changed).
75
+ * `lib/admin.ts` and `lib/api.ts` pass this to the store, so writes through the
76
+ * admin or the REST API invalidate without anyone remembering to call it.
77
+ */
78
+ export function revalidateResource({ resource, action, id }: ChangeEvent) {
79
+ // { expire: 0 }: drop the entry now rather than serving it stale while it refreshes.
80
+ // A record someone just edited should never come back with its old values.
81
+ revalidateTag(resourceTag(resource), { expire: 0 });
82
+ if (action !== "create") revalidateTag(recordTag(resource, id), { expire: 0 });
83
+ }
@@ -0,0 +1,36 @@
1
+ import { env } from "cloudflare:workers";
2
+ import { createMailer, renderTransactionalEmail, type TransactionalEmail } from "@flaredev/core";
3
+
4
+ /**
5
+ * Transactional email via Resend.
6
+ *
7
+ * Without RESEND_API_KEY (e.g. local dev) emails are printed to the console
8
+ * instead of sent. MAIL_FROM must use a domain verified in Resend. The fallback
9
+ * sandbox sender only delivers to your own Resend account address.
10
+ */
11
+ export const mailer = createMailer({
12
+ apiKey: env.RESEND_API_KEY,
13
+ from: env.MAIL_FROM || "__APP_NAME__ <onboarding@resend.dev>",
14
+ });
15
+
16
+ /**
17
+ * Render and send a transactional email. Resolves to `{ data, error }` and never throws for API errors.
18
+ *
19
+ * const { error } = await sendTransactionalEmail({
20
+ * to: user.email,
21
+ * subject: "Your order has shipped",
22
+ * heading: "On its way",
23
+ * paragraphs: [`Order #${order.id} left our warehouse today.`],
24
+ * action: { label: "Track order", url: `https://example.com/orders/${order.id}` },
25
+ * idempotencyKey: `order-shipped/${order.id}`,
26
+ * });
27
+ */
28
+ export async function sendTransactionalEmail({
29
+ to,
30
+ subject,
31
+ idempotencyKey,
32
+ ...content
33
+ }: Omit<TransactionalEmail, "appName"> & { to: string | string[]; subject: string; idempotencyKey?: string }) {
34
+ const { html, text } = renderTransactionalEmail({ appName: "__APP_NAME__", ...content });
35
+ return mailer.send({ to, subject, html, text, idempotencyKey });
36
+ }
@@ -0,0 +1,45 @@
1
+ import {
2
+ realtimeHub,
3
+ type RealtimeAuthorize,
4
+ type RealtimeChannel,
5
+ type RealtimeChannelNamespace,
6
+ } from "@flaredev/core/realtime/server";
7
+ import { env } from "cloudflare:workers";
8
+ import { auth } from "./auth";
9
+
10
+ /**
11
+ * Who may open a realtime connection, per channel. `worker/index.ts` calls this for
12
+ * every `/realtime/<channel>/ws` upgrade, after rejecting cross-site origins.
13
+ *
14
+ * Default: any signed-in user may join any channel and receive events and
15
+ * presence; nobody may broadcast from the browser (server code publishes with
16
+ * `realtimeChannel(...).publish`). Tighten per channel as your app needs, e.g.
17
+ *
18
+ * if (channel === "deals" && !["admin", "staff"].includes(role ?? "")) return false;
19
+ * if (channel === `user-${session.user.id}`) return { send: true };
20
+ */
21
+ export const authorizeRealtime: RealtimeAuthorize = async (request) => {
22
+ // disableRefresh: this runs outside a Next request, where refreshing the
23
+ // session cookie has nowhere to write it.
24
+ const session = await auth.api.getSession({ headers: request.headers, query: { disableRefresh: true } });
25
+ if (!session) return false;
26
+ return { send: false };
27
+ };
28
+
29
+ /** The FLARE_REALTIME binding. `env` is the worker runtime binding object; its type is app-generated. */
30
+ function binding(): RealtimeChannelNamespace {
31
+ return (env as unknown as { FLARE_REALTIME: RealtimeChannelNamespace }).FLARE_REALTIME;
32
+ }
33
+
34
+ /**
35
+ * An RPC handle for a realtime channel that server code can publish to.
36
+ *
37
+ * ```ts
38
+ * // In a route handler or server action, after something the UI should
39
+ * // live-update on, await the RPC (DO stubs return promises):
40
+ * await realtimeChannel("deals").publish("deal.updated", { id, title });
41
+ * ```
42
+ */
43
+ export function realtimeChannel(name: string): RealtimeChannel {
44
+ return realtimeHub(binding(), name);
45
+ }
@@ -0,0 +1,13 @@
1
+ // Request protection for worker/index.ts. Security is off until you run
2
+ // `flare gen security`, which rewrites the generated block below.
3
+ // generated:start
4
+ /** Minimal ExecutionContext surface worker/index.ts passes in. */
5
+ export interface SecurityContext {
6
+ waitUntil(promise: Promise<unknown>): void;
7
+ }
8
+
9
+ /** Security is not enabled yet: every request goes straight to the app. */
10
+ export async function protect(_request: Request, _env: unknown, _ctx: SecurityContext, next: () => Promise<Response>): Promise<Response> {
11
+ return next();
12
+ }
13
+ // generated:end
@@ -0,0 +1,24 @@
1
+ import { headers } from "next/headers";
2
+ import { redirect } from "next/navigation";
3
+ import { auth } from "./auth";
4
+
5
+ /** The current session, or null. Validates against the database: use in server components, actions and route handlers. */
6
+ export async function getSession() {
7
+ return auth.api.getSession({ headers: await headers() });
8
+ }
9
+
10
+ /** The current session; redirects to /sign-in (returning to `returnTo` afterwards) when signed out. */
11
+ export async function requireSession(returnTo?: string) {
12
+ const session = await getSession();
13
+ if (!session) {
14
+ redirect(returnTo ? `/sign-in?next=${encodeURIComponent(returnTo)}` : "/sign-in");
15
+ }
16
+ return session;
17
+ }
18
+
19
+ /** Only allow same-site relative redirect targets, to avoid open redirects via `?next=`. */
20
+ export function safeRedirectPath(value: unknown, fallback = "/dashboard"): string {
21
+ return typeof value === "string" && value.startsWith("/") && !value.startsWith("//") && !value.startsWith("/\\")
22
+ ? value
23
+ : fallback;
24
+ }
@@ -0,0 +1,26 @@
1
+ import { env } from "cloudflare:workers";
2
+ import { createObjectKey, createStorage } from "@flaredev/core";
3
+
4
+ /**
5
+ * File storage on the `STORAGE` R2 bucket.
6
+ *
7
+ * `createUploadUrl` / `createReadUrl` return short-lived signed URLs redeemed by
8
+ * `app/api/storage/route.ts`. A URL grants access to whoever holds it, so check
9
+ * that the current user may upload or view the object before issuing one.
10
+ *
11
+ * const { url } = await storage.createUploadUrl({
12
+ * key: createObjectKey(file.name, `users/${user.id}`),
13
+ * contentTypes: ["image/*", "application/pdf"],
14
+ * maxBytes: 10 * 1024 * 1024,
15
+ * });
16
+ * // browser: fetch(url, { method: "PUT", body: file, headers: { "Content-Type": file.type } })
17
+ *
18
+ * const src = await storage.createReadUrl({ key });
19
+ */
20
+ export const storage = createStorage({
21
+ bucket: env.STORAGE,
22
+ // Signing keys are derived from the auth secret with a storage-specific label.
23
+ secret: env.BETTER_AUTH_SECRET,
24
+ });
25
+
26
+ export { createObjectKey };
@@ -0,0 +1,6 @@
1
+ import { clsx, type ClassValue } from "clsx";
2
+ import { twMerge } from "tailwind-merge";
3
+
4
+ export function cn(...inputs: ClassValue[]) {
5
+ return twMerge(clsx(inputs));
6
+ }
@@ -0,0 +1,66 @@
1
+ CREATE TABLE `account` (
2
+ `id` text PRIMARY KEY NOT NULL,
3
+ `account_id` text NOT NULL,
4
+ `provider_id` text NOT NULL,
5
+ `user_id` text NOT NULL,
6
+ `access_token` text,
7
+ `refresh_token` text,
8
+ `id_token` text,
9
+ `access_token_expires_at` integer,
10
+ `refresh_token_expires_at` integer,
11
+ `scope` text,
12
+ `password` text,
13
+ `created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
14
+ `updated_at` integer NOT NULL,
15
+ FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
16
+ );
17
+ --> statement-breakpoint
18
+ CREATE INDEX `account_userId_idx` ON `account` (`user_id`);--> statement-breakpoint
19
+ CREATE TABLE `session` (
20
+ `id` text PRIMARY KEY NOT NULL,
21
+ `expires_at` integer NOT NULL,
22
+ `token` text NOT NULL,
23
+ `created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
24
+ `updated_at` integer NOT NULL,
25
+ `ip_address` text,
26
+ `user_agent` text,
27
+ `user_id` text NOT NULL,
28
+ `impersonated_by` text,
29
+ FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
30
+ );
31
+ --> statement-breakpoint
32
+ CREATE UNIQUE INDEX `session_token_unique` ON `session` (`token`);--> statement-breakpoint
33
+ CREATE INDEX `session_userId_idx` ON `session` (`user_id`);--> statement-breakpoint
34
+ CREATE TABLE `user` (
35
+ `id` text PRIMARY KEY NOT NULL,
36
+ `name` text NOT NULL,
37
+ `email` text NOT NULL,
38
+ `email_verified` integer DEFAULT false NOT NULL,
39
+ `image` text,
40
+ `created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
41
+ `updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
42
+ `role` text,
43
+ `banned` integer DEFAULT false,
44
+ `ban_reason` text,
45
+ `ban_expires` integer
46
+ );
47
+ --> statement-breakpoint
48
+ CREATE UNIQUE INDEX `user_email_unique` ON `user` (`email`);--> statement-breakpoint
49
+ CREATE TABLE `verification` (
50
+ `id` text PRIMARY KEY NOT NULL,
51
+ `identifier` text NOT NULL,
52
+ `value` text NOT NULL,
53
+ `expires_at` integer NOT NULL,
54
+ `created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
55
+ `updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL
56
+ );
57
+ --> statement-breakpoint
58
+ CREATE INDEX `verification_identifier_idx` ON `verification` (`identifier`);--> statement-breakpoint
59
+ CREATE TABLE `role` (
60
+ `name` text PRIMARY KEY NOT NULL,
61
+ `label` text NOT NULL,
62
+ `created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL
63
+ );
64
+ --> statement-breakpoint
65
+ -- Built-in roles: lib/admin.ts lets both into /admin. Add more with `flare role:add`.
66
+ INSERT INTO `role` (`name`, `label`) VALUES ('admin', 'Admin'), ('staff', 'Staff');