@flaredev/cli 0.4.0 → 0.5.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 (59) hide show
  1. package/dist/index.js +851 -405
  2. package/package.json +2 -2
  3. package/templates/app/app/dashboard/actions.ts +38 -5
  4. package/templates/app/app/dashboard/costs/page.tsx +93 -0
  5. package/templates/app/app/dashboard/page.tsx +4 -7
  6. package/templates/app/app/globals.css +0 -6
  7. package/templates/app/app/layout.tsx +37 -28
  8. package/templates/app/app/page.tsx +10 -0
  9. package/templates/app/components/dashboard/cost-estimator.tsx +77 -0
  10. package/templates/app/components/dashboard/fields/field-widget.tsx +237 -186
  11. package/templates/app/components/dashboard/fields/file-field.tsx +5 -1
  12. package/templates/app/components/dashboard/fields/relation-field.tsx +25 -3
  13. package/templates/app/components/dashboard/import-dialog.tsx +2 -2
  14. package/templates/app/components/dashboard/record-actions.tsx +9 -9
  15. package/templates/app/components/dashboard/record-detail.tsx +6 -3
  16. package/templates/app/components/dashboard/resource-form-page.tsx +64 -61
  17. package/templates/app/components/dashboard/resource-form-sheet.tsx +86 -0
  18. package/templates/app/components/dashboard/resource-form.tsx +261 -165
  19. package/templates/app/components/dashboard/resource-icon.tsx +87 -85
  20. package/templates/app/components/dashboard/resource-stats.tsx +10 -9
  21. package/templates/app/components/dashboard/resource-table-toolbar.tsx +2 -2
  22. package/templates/app/components/dashboard/resource-table.tsx +12 -9
  23. package/templates/app/components/dashboard/row-actions.tsx +5 -5
  24. package/templates/app/lib/costs.ts +138 -0
  25. package/templates/app/lib/dashboard-nav.ts +5 -1
  26. package/templates/app/lib/dashboard.ts +66 -61
  27. package/templates/app/lib/number.ts +33 -0
  28. package/templates/app/lib/site.ts +56 -56
  29. package/templates/app/lib/suggest.ts +35 -0
  30. package/templates/app/lib/usage.ts +32 -0
  31. package/templates/locks/package-lock.json +9 -9
  32. package/templates/locks/pnpm-lock.yaml +12 -12
  33. package/templates/locks/yarn.lock +3 -3
  34. package/templates/next/.env.example +21 -0
  35. package/templates/next/README.md +16 -0
  36. package/templates/next/app/api/health/route.ts +7 -0
  37. package/templates/next/app/dashboard/account/password/page.tsx +19 -0
  38. package/templates/next/app/dashboard/account/security/page.tsx +31 -0
  39. package/templates/next/app/dashboard/actions.ts +277 -0
  40. package/templates/next/app/dashboard/layout.tsx +64 -0
  41. package/templates/next/app/dashboard/page.tsx +127 -0
  42. package/templates/next/components/dashboard/resource-chart.tsx +86 -0
  43. package/templates/next/lib/analytics.ts +71 -0
  44. package/templates/next/lib/audit.ts +80 -0
  45. package/templates/next/lib/auth-emails.ts +92 -0
  46. package/templates/next/lib/auth.ts +156 -0
  47. package/templates/next/lib/dashboard.ts +161 -0
  48. package/templates/next/lib/db.ts +24 -0
  49. package/templates/next/lib/mail.ts +35 -0
  50. package/templates/next/lib/realtime.ts +23 -0
  51. package/templates/next/lib/redis.ts +17 -0
  52. package/templates/next/lib/storage.ts +57 -0
  53. package/templates/next/lib/theme.ts +83 -0
  54. package/templates/next/lib/views.ts +23 -0
  55. package/templates/next/next.config.ts +13 -0
  56. package/templates/next/prisma/schema/base.prisma +162 -0
  57. package/templates/next/prisma.config.ts +15 -0
  58. package/templates/next/tsconfig.json +24 -0
  59. package/templates/app/components/dashboard/resource-form-dialog.tsx +0 -80
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flaredev/cli",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "The flare command-line tool: create, generate, migrate, and deploy Flare apps",
5
5
  "license": "MIT",
6
6
  "author": "Muke Johnbaptist",
@@ -41,7 +41,7 @@
41
41
  "jiti": "^2.7.0",
42
42
  "jsonc-parser": "^3.3.1",
43
43
  "picocolors": "^1.1.1",
44
- "@flaredev/core": "0.4.0"
44
+ "@flaredev/core": "0.5.0"
45
45
  },
46
46
  "engines": {
47
47
  "node": ">=22"
@@ -10,7 +10,7 @@ import { getDb } from "@/db";
10
10
  import { savedView } from "@/db/flare-schema";
11
11
  import { toCsv } from "@/lib/csv";
12
12
  import { storage } from "@/lib/storage";
13
- import { can, storedFields, type PolicyAction } from "@flaredev/core";
13
+ import { can, storedFields, type PolicyAction, type Resource } from "@flaredev/core";
14
14
  import { resourcePath, dashboardSession, dashboardStore, policyFor } from "@/lib/dashboard";
15
15
 
16
16
  export type ActionResult<T = unknown> =
@@ -26,6 +26,23 @@ async function allowed(resourceName: string, action: PolicyAction): Promise<Acti
26
26
  if (!can(policyFor(resourceName), role, action)) return forbidden(`Your role can't ${action} this record.`);
27
27
  }
28
28
 
29
+ /**
30
+ * Take a deleted record's uploads out of storage.
31
+ *
32
+ * The row is gone, so nothing can point at those objects again, and an app that never
33
+ * removes them pays to keep files no one can reach. A failure here is swallowed on
34
+ * purpose: the record is already deleted, and a stray object is a smaller problem than
35
+ * an action that reports failure after having done the work.
36
+ */
37
+ async function removeUploads(resource: Resource, record: Record<string, unknown> | null | undefined): Promise<void> {
38
+ if (!record) return;
39
+ for (const [key, def] of storedFields(resource)) {
40
+ if (def.kind !== "file") continue;
41
+ const value = record[key];
42
+ if (typeof value === "string" && value) await storage.delete(value).catch(() => undefined);
43
+ }
44
+ }
45
+
29
46
  export async function deleteRecordAction(resourceName: string, id: string): Promise<ActionResult<{ id: string }>> {
30
47
  const denied = await allowed(resourceName, "delete");
31
48
  if (denied) return denied;
@@ -34,6 +51,7 @@ export async function deleteRecordAction(resourceName: string, id: string): Prom
34
51
  const existing = await store.get(id);
35
52
  const result = await store.delete(id);
36
53
  if (result.ok) {
54
+ await removeUploads(store.resource, existing.ok ? existing.data : null);
37
55
  await recordAudit({
38
56
  action: "delete",
39
57
  resource: resourceName,
@@ -67,10 +85,15 @@ export async function deleteManyAction(resourceName: string, ids: string[]): Pro
67
85
  // at it) shouldn't take the rest of the batch with it.
68
86
  let deleted = 0;
69
87
  let failed = 0;
88
+ const hasFiles = storedFields(store.resource).some(([, def]) => def.kind === "file");
70
89
  for (const id of ids) {
90
+ // Only read the record first when there's an upload to clean up afterwards.
91
+ const existing = hasFiles ? await store.get(id) : null;
71
92
  const result = await store.delete(id);
72
- if (result.ok) deleted++;
73
- else failed++;
93
+ if (result.ok) {
94
+ deleted++;
95
+ if (existing?.ok) await removeUploads(store.resource, existing.data);
96
+ } else failed++;
74
97
  }
75
98
  if (deleted > 0) {
76
99
  await recordAudit({ action: "bulk-delete", resource: resourceName, changes: { deleted, failed } });
@@ -233,7 +256,17 @@ export async function createUploadUrlAction(
233
256
  * field's prefix are signed, and only for roles that can work with the resource, so a
234
257
  * guessed or borrowed key can't be read through a resource the role can't see.
235
258
  */
236
- export async function createReadUrlAction(resourceName: string, fieldKey: string, key: string): Promise<ActionResult<{ url: string }>> {
259
+ export async function createReadUrlAction(
260
+ resourceName: string,
261
+ fieldKey: string,
262
+ key: string,
263
+ /**
264
+ * Serve the file as a download with this name instead of showing it in the tab. Object
265
+ * keys carry an id to keep them unique, so without this a download arrives called
266
+ * `3f1c…-invoice.pdf` — pass the name the record holds.
267
+ */
268
+ downloadAs?: string,
269
+ ): Promise<ActionResult<{ url: string }>> {
237
270
  const denied = await allowed(resourceName, "read");
238
271
  // Someone creating or editing a record can view what they just uploaded.
239
272
  if (denied && (await allowed(resourceName, "create")) && (await allowed(resourceName, "update"))) return denied;
@@ -242,5 +275,5 @@ export async function createReadUrlAction(resourceName: string, fieldKey: string
242
275
  if ("failure" in target) return target.failure;
243
276
  if (!key.startsWith(target.prefix)) return { ok: false, status: 403, error: "That file doesn't belong to this field.", field: fieldKey };
244
277
 
245
- return { ok: true, data: { url: await storage.createReadUrl({ key }) } };
278
+ return { ok: true, data: { url: await storage.createReadUrl({ key, downloadAs: downloadAs || undefined }) } };
246
279
  }
@@ -0,0 +1,93 @@
1
+ import { DatabaseIcon, FilesIcon, HardDriveIcon, LayersIcon } from "lucide-react";
2
+ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
3
+ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
4
+ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
5
+ import { CostEstimator } from "@/components/dashboard/cost-estimator";
6
+ import { PageHeader } from "@/components/dashboard/page-header";
7
+ import { StatCards } from "@/components/dashboard/stat-card";
8
+ import { compact, FREE, gb } from "@/lib/costs";
9
+ import { requireDashboard, resourceStats, visibleResources } from "@/lib/dashboard";
10
+ import { storedBytes } from "@/lib/usage";
11
+
12
+ export const metadata = { title: "Costs" };
13
+
14
+ /**
15
+ * What this app holds, and what it would cost to run.
16
+ *
17
+ * Two halves, and the difference between them matters. The top is measured: rows this
18
+ * app has stored, files it has put in R2. The bottom is modelled, because a Worker can
19
+ * see what it wrote but not how many requests it served — that lives in Cloudflare's
20
+ * analytics, behind an API token this app doesn't hold and shouldn't need.
21
+ */
22
+ export default async function CostsPage() {
23
+ await requireDashboard("/dashboard/costs");
24
+ const resources = await visibleResources();
25
+
26
+ const [counts, stored] = await Promise.all([
27
+ Promise.all(resources.map(async (resource) => ({ resource, total: (await resourceStats(resource.name)).total }))),
28
+ storedBytes(),
29
+ ]);
30
+
31
+ const rows = counts.reduce((sum, entry) => sum + entry.total, 0);
32
+ const r2Gb = stored.bytes / 1024 ** 3;
33
+
34
+ return (
35
+ <>
36
+ <PageHeader
37
+ title="Costs"
38
+ description="What this app holds, and what it would cost to run."
39
+ crumbs={[{ label: "Dashboard", href: "/dashboard" }, { label: "Costs" }]}
40
+ />
41
+
42
+ <StatCards
43
+ stats={[
44
+ { label: "Rows stored", value: compact(rows), icon: DatabaseIcon, hint: `across ${resources.length} resources` },
45
+ { label: "Files in R2", value: compact(stored.files), icon: FilesIcon },
46
+ { label: "File storage", value: gb(r2Gb), icon: HardDriveIcon, hint: `${FREE.r2StorageGb} GB free` },
47
+ { label: "Resources", value: resources.length, icon: LayersIcon },
48
+ ]}
49
+ />
50
+
51
+ <Card>
52
+ <CardHeader>
53
+ <CardTitle>What's in the database</CardTitle>
54
+ </CardHeader>
55
+ <CardContent>
56
+ <Table>
57
+ <TableHeader>
58
+ <TableRow>
59
+ <TableHead>Resource</TableHead>
60
+ <TableHead className="text-right">Rows</TableHead>
61
+ </TableRow>
62
+ </TableHeader>
63
+ <TableBody>
64
+ {counts.map(({ resource, total }) => (
65
+ <TableRow key={resource.name}>
66
+ <TableCell>{resource.pluralLabel}</TableCell>
67
+ <TableCell className="text-right tabular-nums">{total.toLocaleString()}</TableCell>
68
+ </TableRow>
69
+ ))}
70
+ </TableBody>
71
+ </Table>
72
+ </CardContent>
73
+ </Card>
74
+
75
+ <CostEstimator storedGb={{ d1: 0, r2: r2Gb }} />
76
+
77
+ <Alert>
78
+ <AlertTitle>This is an estimate, not a bill</AlertTitle>
79
+ <AlertDescription>
80
+ A Worker can see what it stored; it can't see how many requests it served or how many rows it read. Those are in{" "}
81
+ <a href="https://dash.cloudflare.com" target="_blank" rel="noreferrer" className="underline">
82
+ your Cloudflare dashboard
83
+ </a>
84
+ . The assumptions behind the numbers above are in <code>lib/costs.ts</code>, and the reasoning is in the{" "}
85
+ <a href="https://flare-docs.codetotech.com/guides/costs/" target="_blank" rel="noreferrer" className="underline">
86
+ cost guide
87
+ </a>
88
+ .
89
+ </AlertDescription>
90
+ </Alert>
91
+ </>
92
+ );
93
+ }
@@ -10,7 +10,7 @@ import { StatCards, type Stat } from "@/components/dashboard/stat-card";
10
10
  import { getDb } from "@/db";
11
11
  import { passkey } from "@/db/auth-schema";
12
12
  import { authConfig, twoFactorAvailable } from "@/lib/auth-config";
13
- import { adminPermissions, recentCounts, recordCount, resourcePath, trend, visibleResources } from "@/lib/dashboard";
13
+ import { adminPermissions, resourcePath, resourceStats, trend, visibleResources } from "@/lib/dashboard";
14
14
  import { requireSession } from "@/lib/session";
15
15
 
16
16
  export const metadata = { title: "Dashboard" };
@@ -22,12 +22,9 @@ export default async function DashboardPage() {
22
22
 
23
23
  const cards = await Promise.all(
24
24
  resources.map(async (resource) => {
25
- const [total, recent, permissions] = await Promise.all([
26
- recordCount(resource.name),
27
- recentCounts(resource.name),
28
- adminPermissions(resource.name),
29
- ]);
30
- return { resource, total, recent, permissions };
25
+ // One query per resource for the count and the trend, not two.
26
+ const [stats, permissions] = await Promise.all([resourceStats(resource.name), adminPermissions(resource.name)]);
27
+ return { resource, total: stats.total, recent: stats, permissions };
31
28
  }),
32
29
  );
33
30
 
@@ -1,11 +1,5 @@
1
1
  @import "tailwindcss";
2
2
  @import "tw-animate-css";
3
- /* One typeface per theme (self-hosted; only the active theme's font is downloaded). */
4
- @import "@fontsource-variable/inter";
5
- @import "@fontsource-variable/plus-jakarta-sans";
6
- @import "@fontsource-variable/noto-sans";
7
- @import "@fontsource-variable/geist";
8
- @import "@fontsource-variable/figtree";
9
3
 
10
4
  /*
11
5
  * Dark mode: the `dark` class on <html> (set from the theme cookie) or, with no
@@ -1,28 +1,37 @@
1
- import type { Metadata } from "next";
2
- import { site } from "@/lib/site";
3
- import { activeTheme } from "@/lib/theme";
4
- import "./globals.css";
5
-
6
- export const metadata: Metadata = {
7
- title: { default: site.name, template: `%s · ${site.name}` },
8
- description: site.description,
9
- };
10
-
11
- /**
12
- * Applies the saved theme (the `flare-theme` cookie the admin's toggle writes) before
13
- * first paint. It runs in the browser rather than reading cookies() here: a layout
14
- * that reads cookies makes every page under it dynamic, and dynamic pages can never
15
- * be served from the CDN cache.
16
- */
17
- const themeScript = `(function(){try{var m=document.cookie.match(/(?:^|; )flare-theme=(light|dark)/);if(m)document.documentElement.classList.add(m[1]);}catch(e){}})();`;
18
-
19
- export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
20
- return (
21
- <html lang="en" data-theme={activeTheme} suppressHydrationWarning>
22
- <head>
23
- <script dangerouslySetInnerHTML={{ __html: themeScript }} />
24
- </head>
25
- <body>{children}</body>
26
- </html>
27
- );
28
- }
1
+ import type { Metadata } from "next";
2
+ import { site } from "@/lib/site";
3
+ import { activeTheme } from "@/lib/theme";
4
+ import "./globals.css";
5
+ // One typeface per theme, self-hosted; the browser only downloads the one in use.
6
+ // These are imported here rather than from globals.css because Tailwind inlines a CSS
7
+ // @import, and the font files' relative URLs are then resolved against the wrong file
8
+ // and never emitted — which shows up only in a build, as every font 404ing.
9
+ import "@fontsource-variable/inter";
10
+ import "@fontsource-variable/plus-jakarta-sans";
11
+ import "@fontsource-variable/noto-sans";
12
+ import "@fontsource-variable/geist";
13
+ import "@fontsource-variable/figtree";
14
+
15
+ export const metadata: Metadata = {
16
+ title: { default: site.name, template: `%s · ${site.name}` },
17
+ description: site.description,
18
+ };
19
+
20
+ /**
21
+ * Applies the saved theme (the `flare-theme` cookie the admin's toggle writes) before
22
+ * first paint. It runs in the browser rather than reading cookies() here: a layout
23
+ * that reads cookies makes every page under it dynamic, and dynamic pages can never
24
+ * be served from the CDN cache.
25
+ */
26
+ const themeScript = `(function(){try{var m=document.cookie.match(/(?:^|; )flare-theme=(light|dark)/);if(m)document.documentElement.classList.add(m[1]);}catch(e){}})();`;
27
+
28
+ export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
29
+ return (
30
+ <html lang="en" data-theme={activeTheme} suppressHydrationWarning>
31
+ <head>
32
+ <script dangerouslySetInnerHTML={{ __html: themeScript }} />
33
+ </head>
34
+ <body>{children}</body>
35
+ </html>
36
+ );
37
+ }
@@ -114,6 +114,16 @@ export default function Home() {
114
114
  <p>
115
115
  © {new Date().getFullYear()} {site.name}
116
116
  </p>
117
+ {/* Delete this line when the app is your own — it's here so whoever set the
118
+ project up can find what running it actually costs. */}
119
+ <a
120
+ href="https://flare-docs.codetotech.com/guides/costs/"
121
+ target="_blank"
122
+ rel="noreferrer"
123
+ className="hover:text-foreground"
124
+ >
125
+ Built with Flare · what it costs to run
126
+ </a>
117
127
  <nav aria-label="Footer" className="flex gap-6">
118
128
  <Link href={site.links.terms} className="hover:text-foreground">
119
129
  Terms
@@ -0,0 +1,77 @@
1
+ "use client";
2
+
3
+ import { useMemo, useState } from "react";
4
+ import { Card, CardContent } from "@/components/ui/card";
5
+ import { Field, FieldDescription, FieldLabel } from "@/components/ui/field";
6
+ import { Input } from "@/components/ui/input";
7
+ import { compact, estimate, PER_VIEW, usd } from "@/lib/costs";
8
+ import { groupDigits, ungroup } from "@/lib/number";
9
+
10
+ /**
11
+ * What a month would cost at a traffic level you choose.
12
+ *
13
+ * The arithmetic is in lib/costs.ts along with the per-view assumptions it rests on.
14
+ * This is a model, not a bill — the app can see what it stores, not what it served.
15
+ */
16
+ export function CostEstimator({ storedGb }: { storedGb: { d1: number; r2: number } }) {
17
+ const [views, setViews] = useState("50000");
18
+ const monthly = Math.max(0, Number(views) || 0);
19
+ const result = useMemo(() => estimate(monthly, storedGb), [monthly, storedGb]);
20
+
21
+ const rows = [
22
+ { label: "Requests", value: compact(result.requests), of: "10M included" },
23
+ { label: "D1 rows read", value: compact(result.d1RowsRead), of: "25B included" },
24
+ { label: "D1 rows written", value: compact(result.d1RowsWritten), of: "50M included" },
25
+ { label: "KV reads", value: compact(result.kvReads), of: "10M included" },
26
+ { label: "KV writes", value: compact(result.kvWrites), of: "1M included" },
27
+ { label: "CPU", value: `${compact(result.cpuMs)} ms`, of: "30M ms included" },
28
+ ];
29
+
30
+ return (
31
+ <div className="flex flex-col gap-4">
32
+ <Field className="max-w-xs">
33
+ <FieldLabel htmlFor="views">Page views a month</FieldLabel>
34
+ <Input
35
+ id="views"
36
+ inputMode="numeric"
37
+ className="tabular-nums"
38
+ value={groupDigits(views)}
39
+ onChange={(event) => setViews(ungroup(event.target.value, false))}
40
+ />
41
+ <FieldDescription>
42
+ At {PER_VIEW.d1RowsRead} rows read and {PER_VIEW.cpuMs} ms of CPU per view.
43
+ </FieldDescription>
44
+ </Field>
45
+
46
+ <Card>
47
+ <CardContent className="flex flex-col gap-4">
48
+ <div className="flex flex-wrap items-baseline justify-between gap-2">
49
+ <span className="text-sm text-muted-foreground">Estimated monthly cost</span>
50
+ <span className="text-3xl font-semibold tabular-nums">{usd(result.totalUsd)}</span>
51
+ </div>
52
+ <p className="text-xs text-muted-foreground">
53
+ {result.overageUsd < 0.005
54
+ ? "All of it the $5 Workers Paid subscription — this traffic is inside every included allowance."
55
+ : `$5 subscription plus ${usd(result.overageUsd)} of usage past what's included.`}
56
+ </p>
57
+
58
+ <div className="grid gap-x-6 gap-y-2 border-t pt-3 sm:grid-cols-2">
59
+ {rows.map((row) => (
60
+ <div key={row.label} className="flex items-baseline justify-between gap-3 text-sm">
61
+ <span className="text-muted-foreground">{row.label}</span>
62
+ <span className="tabular-nums">
63
+ {row.value} <span className="text-xs text-muted-foreground">/ {row.of}</span>
64
+ </span>
65
+ </div>
66
+ ))}
67
+ </div>
68
+
69
+ <p className="border-t pt-3 text-xs text-muted-foreground">
70
+ <span className="font-medium text-foreground">Free plan: </span>
71
+ {result.freePlanVerdict}
72
+ </p>
73
+ </CardContent>
74
+ </Card>
75
+ </div>
76
+ );
77
+ }