@flaredev/cli 0.10.0 → 0.11.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/dist/index.js +119 -22
- package/package.json +2 -2
- package/templates/app/components/dashboard/resource-form.tsx +14 -3
- package/templates/app/lib/auth.ts +45 -0
- package/templates/app/lib/resource/drizzle-rows.ts +7 -2
- package/templates/app/lib/resource/rows.ts +5 -1
- package/templates/app/lib/resource/store.ts +47 -11
- package/templates/locks/{package-lock.json → cloudflare/package-lock.json} +1200 -1403
- package/templates/locks/{pnpm-lock.yaml → cloudflare/pnpm-lock.yaml} +1081 -1304
- package/templates/locks/{yarn.lock → cloudflare/yarn.lock} +1088 -1218
- package/templates/locks/next/package-lock.json +13673 -0
- package/templates/locks/next/pnpm-lock.yaml +8232 -0
- package/templates/locks/next/yarn.lock +5747 -0
- package/templates/next/.env.example +26 -10
- package/templates/next/docker-compose.yml +74 -0
- package/templates/next/lib/auth.ts +43 -0
- package/templates/next/lib/db.ts +5 -1
- package/templates/next/lib/resource/prisma-rows.ts +3 -2
package/dist/index.js
CHANGED
|
@@ -141,12 +141,12 @@ function parseTheme(value) {
|
|
|
141
141
|
// src/versions.ts
|
|
142
142
|
var COMPATIBILITY_DATE = "2026-09-15";
|
|
143
143
|
var APP_DEPENDENCIES = {
|
|
144
|
-
"@better-auth/drizzle-adapter": "1.7.
|
|
145
|
-
"@better-auth/passkey": "1.7.
|
|
144
|
+
"@better-auth/drizzle-adapter": "1.7.14",
|
|
145
|
+
"@better-auth/passkey": "1.7.14",
|
|
146
146
|
// Server components import Slot from here rather than the "radix-ui" umbrella (see components/ui/button.tsx).
|
|
147
147
|
"@radix-ui/react-slot": "1.3.3",
|
|
148
148
|
"@vinext/cloudflare": "1.0.0-beta.8",
|
|
149
|
-
"better-auth": "1.7.
|
|
149
|
+
"better-auth": "1.7.14",
|
|
150
150
|
// Admin UI (shadcn/ui primitives in components/ui).
|
|
151
151
|
"class-variance-authority": "^0.7.1",
|
|
152
152
|
clsx: "^2.1.1",
|
|
@@ -172,7 +172,7 @@ var APP_DEPENDENCIES = {
|
|
|
172
172
|
"react-server-dom-webpack": "19.3.0",
|
|
173
173
|
vinext: "1.0.0-beta.10"
|
|
174
174
|
};
|
|
175
|
-
var BETTER_AUTH_VERSION = "1.7.
|
|
175
|
+
var BETTER_AUTH_VERSION = "1.7.14";
|
|
176
176
|
var API_KEY_PACKAGE = "@better-auth/api-key";
|
|
177
177
|
var APP_DEV_DEPENDENCIES = {
|
|
178
178
|
"@cloudflare/vite-plugin": "^1.54.10",
|
|
@@ -198,7 +198,7 @@ var NEXT_DEPENDENCIES = {
|
|
|
198
198
|
"@prisma/adapter-pg": "7.10.0",
|
|
199
199
|
pg: "^8.16.3",
|
|
200
200
|
"@upstash/redis": "^1.36.0",
|
|
201
|
-
"@better-auth/prisma-adapter": "1.7.
|
|
201
|
+
"@better-auth/prisma-adapter": "1.7.14",
|
|
202
202
|
// Signs S3 requests in a few kilobytes, where the AWS SDK is megabytes.
|
|
203
203
|
aws4fetch: "^1.0.20",
|
|
204
204
|
dotenv: "^17.2.3"
|
|
@@ -233,7 +233,7 @@ var CLOUDFLARE_ONLY = [
|
|
|
233
233
|
// src/stack.ts
|
|
234
234
|
import { existsSync, readFileSync, writeFileSync } from "fs";
|
|
235
235
|
import { join } from "path";
|
|
236
|
-
var STACKS = ["
|
|
236
|
+
var STACKS = ["next", "cloudflare"];
|
|
237
237
|
var isStack = (value) => STACKS.includes(value);
|
|
238
238
|
var STACK_LABELS = {
|
|
239
239
|
cloudflare: "Cloudflare Workers, D1 and Drizzle \u2014 cheapest to run, deploys everywhere",
|
|
@@ -357,7 +357,7 @@ function createApp(target, options2 = {}) {
|
|
|
357
357
|
if (options2.stack !== void 0 && !isStack(options2.stack)) {
|
|
358
358
|
throw new Error(`Unknown stack "${options2.stack}". Use ${STACKS.join(" or ")}.`);
|
|
359
359
|
}
|
|
360
|
-
const stack = options2.stack ?? "
|
|
360
|
+
const stack = options2.stack ?? "next";
|
|
361
361
|
const authProviders = parseAuthProviders(options2.authProviders);
|
|
362
362
|
const authMethods = parseAuthMethods(options2.auth);
|
|
363
363
|
const theme = parseTheme(options2.theme);
|
|
@@ -376,9 +376,8 @@ function createApp(target, options2 = {}) {
|
|
|
376
376
|
writeFileSync3(join4(dir, "lib", "auth-config.ts"), renderAuthConfig(authMethods, authProviders));
|
|
377
377
|
appendFileSync(join4(dir, stack === "next" ? ".env.example" : ".dev.vars.example"), devVarsExampleEntries(authProviders));
|
|
378
378
|
const shared = { react: APP_DEPENDENCIES.react, "react-dom": APP_DEPENDENCIES["react-dom"] };
|
|
379
|
-
const
|
|
380
|
-
|
|
381
|
-
);
|
|
379
|
+
const cloudflareOnlyPackages = ["vinext", "@vinext/cloudflare", "drizzle-orm", "react-server-dom-webpack", "@better-auth/drizzle-adapter"];
|
|
380
|
+
const uiOnly = Object.fromEntries(Object.entries(APP_DEPENDENCIES).filter(([key]) => !cloudflareOnlyPackages.includes(key)));
|
|
382
381
|
writeJson(join4(dir, "package.json"), {
|
|
383
382
|
name,
|
|
384
383
|
version: "0.1.0",
|
|
@@ -417,7 +416,8 @@ function createApp(target, options2 = {}) {
|
|
|
417
416
|
`BETTER_AUTH_SECRET=${randomBytes(32).toString("base64")}
|
|
418
417
|
RESEND_API_KEY=
|
|
419
418
|
MAIL_FROM=
|
|
420
|
-
${devVarsEntries(authProviders)}`
|
|
419
|
+
${devVarsEntries(authProviders)}` + (stack === "next" ? `
|
|
420
|
+
${LOCAL_SERVICES}` : "")
|
|
421
421
|
);
|
|
422
422
|
if (packageManager === "pnpm" && !inWorkspace) {
|
|
423
423
|
writeFileSync3(join4(dir, "pnpm-workspace.yaml"), `allowBuilds:
|
|
@@ -425,16 +425,28 @@ ${ALLOW_BUILDS}
|
|
|
425
425
|
`);
|
|
426
426
|
}
|
|
427
427
|
const pinned = flarePackageSpec("core", dir, packageManager).startsWith("^");
|
|
428
|
-
if (pinned) copyLockfile(packageManager, dir);
|
|
428
|
+
if (pinned) copyLockfile(packageManager, dir, stack);
|
|
429
429
|
const log2 = options2.log ?? ((message) => console.log(message));
|
|
430
430
|
log2(`${pc.green("\u2714")} Wrote ${files} files to ${relative(process.cwd(), dir) || "."}`);
|
|
431
431
|
if (prefersPnpm) log2(pc.dim(`Installing with pnpm \u2014 far quicker for an app this size. Use ${pc.bold("--pm npm")} to change that.`));
|
|
432
432
|
return { dir, name, packageManager, inWorkspace };
|
|
433
433
|
}
|
|
434
|
-
|
|
434
|
+
var LOCAL_SERVICES = [
|
|
435
|
+
"# Local services (docker-compose.yml). Swap these for Neon, R2 and Upstash to deploy.",
|
|
436
|
+
"DATABASE_URL=postgresql://flare:flare@127.0.0.1:5432/app",
|
|
437
|
+
"BETTER_AUTH_URL=http://localhost:3000",
|
|
438
|
+
"R2_ENDPOINT=http://127.0.0.1:9000",
|
|
439
|
+
"R2_BUCKET=app-uploads",
|
|
440
|
+
"R2_ACCESS_KEY_ID=flare",
|
|
441
|
+
"R2_SECRET_ACCESS_KEY=flare-secret-123",
|
|
442
|
+
"UPSTASH_REDIS_REST_URL=http://127.0.0.1:8079",
|
|
443
|
+
"UPSTASH_REDIS_REST_TOKEN=flare-local-token",
|
|
444
|
+
""
|
|
445
|
+
].join("\n");
|
|
446
|
+
function copyLockfile(packageManager, dir, stack) {
|
|
435
447
|
const file = packageManager === "pnpm" ? "pnpm-lock.yaml" : packageManager === "yarn" ? "yarn.lock" : "package-lock.json";
|
|
436
448
|
if (!file) return false;
|
|
437
|
-
const source = join4(templatesDir, "locks", file);
|
|
449
|
+
const source = join4(templatesDir, "locks", stack, file);
|
|
438
450
|
if (!existsSync4(source)) return false;
|
|
439
451
|
copyFileSync(source, join4(dir, file));
|
|
440
452
|
return true;
|
|
@@ -1692,11 +1704,37 @@ function list(raw, what, spec) {
|
|
|
1692
1704
|
return items;
|
|
1693
1705
|
}
|
|
1694
1706
|
var RESOURCE_NAME = /^[A-Z][A-Za-z0-9]*$/;
|
|
1707
|
+
var CONDITION = /^(.*?)\s+if\s+(.+)$/s;
|
|
1708
|
+
var COMPARISON = /^([A-Za-z][A-Za-z0-9_]*)\s*(==|!=|\bin\b)\s*(.+)$/;
|
|
1709
|
+
function parseCondition(clause, spec) {
|
|
1710
|
+
const match = COMPARISON.exec(clause.trim());
|
|
1711
|
+
if (!match) {
|
|
1712
|
+
throw new FieldGrammarError(`"${spec}": "${clause.trim()}" isn't a condition. Write it like "if kind == recurring".`);
|
|
1713
|
+
}
|
|
1714
|
+
const [, dependsOn, operator, rawValue] = match;
|
|
1715
|
+
const op = operator === "==" ? "eq" : operator === "!=" ? "ne" : "in";
|
|
1716
|
+
const text = rawValue.trim();
|
|
1717
|
+
if (op === "in") {
|
|
1718
|
+
const inner = /^\((.*)\)$/.exec(text);
|
|
1719
|
+
if (!inner) throw new FieldGrammarError(`"${spec}": "in" takes a list in brackets, e.g. "if kind in (recurring, retainer)".`);
|
|
1720
|
+
return { field: dependsOn, op, value: list(inner[1], "condition value", spec) };
|
|
1721
|
+
}
|
|
1722
|
+
if (!text || /\s/.test(text)) {
|
|
1723
|
+
throw new FieldGrammarError(`"${spec}": "${text}" isn't a single value. Write it like "if kind == recurring".`);
|
|
1724
|
+
}
|
|
1725
|
+
return { field: dependsOn, op, value: text };
|
|
1726
|
+
}
|
|
1695
1727
|
function parseField(spec) {
|
|
1696
1728
|
const colon = spec.indexOf(":");
|
|
1697
1729
|
if (colon <= 0) throw new FieldGrammarError(`"${spec}": expected name:type, e.g. "title:string".`);
|
|
1698
1730
|
const rawName = spec.slice(0, colon).trim();
|
|
1699
1731
|
let type = spec.slice(colon + 1).trim();
|
|
1732
|
+
let showWhen;
|
|
1733
|
+
const conditioned = CONDITION.exec(type);
|
|
1734
|
+
if (conditioned) {
|
|
1735
|
+
type = conditioned[1].trim();
|
|
1736
|
+
showWhen = parseCondition(conditioned[2], spec);
|
|
1737
|
+
}
|
|
1700
1738
|
let required = true;
|
|
1701
1739
|
let unique = false;
|
|
1702
1740
|
while (/[?!]$/.test(type)) {
|
|
@@ -1709,6 +1747,7 @@ function parseField(spec) {
|
|
|
1709
1747
|
}
|
|
1710
1748
|
let key = camelCase(rawName);
|
|
1711
1749
|
const field = { key, kind: "string", required, unique };
|
|
1750
|
+
if (showWhen) field.showWhen = showWhen;
|
|
1712
1751
|
let match;
|
|
1713
1752
|
if (SIMPLE_KINDS.includes(type)) {
|
|
1714
1753
|
field.kind = type;
|
|
@@ -1786,6 +1825,40 @@ function parseFields(input) {
|
|
|
1786
1825
|
if (seen.has(field.key)) throw new FieldGrammarError(`Field "${field.key}" is declared more than once.`);
|
|
1787
1826
|
seen.add(field.key);
|
|
1788
1827
|
}
|
|
1828
|
+
const byKey = new Map(fields.map((field) => [field.key, field]));
|
|
1829
|
+
for (const field of fields) {
|
|
1830
|
+
const condition = field.showWhen;
|
|
1831
|
+
if (!condition) continue;
|
|
1832
|
+
if (condition.field === field.key) {
|
|
1833
|
+
throw new FieldGrammarError(`Field "${field.key}" cannot depend on itself.`);
|
|
1834
|
+
}
|
|
1835
|
+
const target = byKey.get(condition.field);
|
|
1836
|
+
if (!target) {
|
|
1837
|
+
const names = fields.map((other) => other.key).filter((name) => name !== field.key);
|
|
1838
|
+
throw new FieldGrammarError(
|
|
1839
|
+
`Field "${field.key}" depends on "${condition.field}", which isn't a field here.${suggest(condition.field, names)} Fields: ${names.join(", ")}.`
|
|
1840
|
+
);
|
|
1841
|
+
}
|
|
1842
|
+
if (target.showWhen) {
|
|
1843
|
+
throw new FieldGrammarError(`Field "${field.key}" depends on "${target.key}", which is itself conditional. Conditions only go one level deep.`);
|
|
1844
|
+
}
|
|
1845
|
+
const values = Array.isArray(condition.value) ? condition.value : [condition.value];
|
|
1846
|
+
if (target.options) {
|
|
1847
|
+
for (const value of values) {
|
|
1848
|
+
if (!target.options.includes(value)) {
|
|
1849
|
+
throw new FieldGrammarError(
|
|
1850
|
+
`Field "${field.key}" is shown when ${condition.field} is "${value}", which isn't one of its options.${suggest(value, target.options)} Options: ${target.options.join(", ")}.`
|
|
1851
|
+
);
|
|
1852
|
+
}
|
|
1853
|
+
}
|
|
1854
|
+
} else if (target.kind === "boolean") {
|
|
1855
|
+
for (const value of values) {
|
|
1856
|
+
if (value !== "true" && value !== "false") {
|
|
1857
|
+
throw new FieldGrammarError(`Field "${field.key}" depends on the boolean "${condition.field}", so compare it with true or false, not "${value}".`);
|
|
1858
|
+
}
|
|
1859
|
+
}
|
|
1860
|
+
}
|
|
1861
|
+
}
|
|
1789
1862
|
return fields;
|
|
1790
1863
|
}
|
|
1791
1864
|
|
|
@@ -1807,6 +1880,7 @@ function options(field) {
|
|
|
1807
1880
|
if (field.widget) opts.widget = field.widget;
|
|
1808
1881
|
if (field.kind === "belongsTo" && !field.required) opts.onDelete = "set null";
|
|
1809
1882
|
if (field.maxBytes !== void 0) opts.maxBytes = field.maxBytes;
|
|
1883
|
+
if (field.showWhen) opts.showWhen = field.showWhen;
|
|
1810
1884
|
return opts;
|
|
1811
1885
|
}
|
|
1812
1886
|
function toField(field) {
|
|
@@ -1830,7 +1904,13 @@ function toField(field) {
|
|
|
1830
1904
|
return builders[field.kind](opts);
|
|
1831
1905
|
}
|
|
1832
1906
|
}
|
|
1833
|
-
var literal = (value) =>
|
|
1907
|
+
var literal = (value) => {
|
|
1908
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
1909
|
+
const entries = Object.entries(value);
|
|
1910
|
+
return `{ ${entries.map(([key, inner]) => `${/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key) ? key : JSON.stringify(key)}: ${literal(inner)}`).join(", ")} }`;
|
|
1911
|
+
}
|
|
1912
|
+
return JSON.stringify(value);
|
|
1913
|
+
};
|
|
1834
1914
|
function renderOptions(opts) {
|
|
1835
1915
|
const entries = Object.entries(opts);
|
|
1836
1916
|
return entries.length ? `{ ${entries.map(([k, v]) => `${k}: ${literal(v)}`).join(", ")} }` : "";
|
|
@@ -2731,7 +2811,7 @@ function renderPrismaModel(entry, all) {
|
|
|
2731
2811
|
if (def.kind === "belongsTo") {
|
|
2732
2812
|
const target = byName.get(def.target);
|
|
2733
2813
|
if (!target) throw new Error(`${resource.name}.${key} belongs to "${def.target}", but there is no ${def.target} resource. Generate it first.`);
|
|
2734
|
-
const optional2 = def.required ? "" : "?";
|
|
2814
|
+
const optional2 = def.required && !def.showWhen ? "" : "?";
|
|
2735
2815
|
const relationField = key.replace(/Id$/, "");
|
|
2736
2816
|
const named = backReferences(target, all).find((back) => back.from === resource.name && back.relationName?.endsWith(`_${key}`));
|
|
2737
2817
|
const relationArgs = [
|
|
@@ -2745,7 +2825,7 @@ function renderPrismaModel(entry, all) {
|
|
|
2745
2825
|
indexes.push(` @@index([${key}])`);
|
|
2746
2826
|
continue;
|
|
2747
2827
|
}
|
|
2748
|
-
const optional = def.required || def.kind === "multiselect" || def.kind === "tags" ? "" : "?";
|
|
2828
|
+
const optional = def.required && !def.showWhen || def.kind === "multiselect" || def.kind === "tags" ? "" : "?";
|
|
2749
2829
|
lines.push(` ${key} ${prismaType(resource, key, def)}${optional} ${attributes(key, def).join(" ")}`.trimEnd());
|
|
2750
2830
|
}
|
|
2751
2831
|
for (const back of backReferences(resource, all)) {
|
|
@@ -2820,7 +2900,7 @@ function columnExpression(key, def, resources, selfName) {
|
|
|
2820
2900
|
default:
|
|
2821
2901
|
expr = `text(${col})`;
|
|
2822
2902
|
}
|
|
2823
|
-
if (def.required) expr += ".notNull()";
|
|
2903
|
+
if (def.required && !def.showWhen) expr += ".notNull()";
|
|
2824
2904
|
if ("unique" in def && def.unique) expr += ".unique()";
|
|
2825
2905
|
if ("default" in def && def.default !== void 0) expr += `.default(${q2(def.default)})`;
|
|
2826
2906
|
return expr;
|
|
@@ -4137,7 +4217,7 @@ async function askCreateQuestions(given, appName) {
|
|
|
4137
4217
|
answers.stack = stopIfCancelled(
|
|
4138
4218
|
await prompts2.select({
|
|
4139
4219
|
message: "Where will it run?",
|
|
4140
|
-
initialValue: "
|
|
4220
|
+
initialValue: "next",
|
|
4141
4221
|
options: STACKS.map((value) => ({ value, label: value === "next" ? "Next.js" : "Cloudflare", hint: STACK_LABELS[value] }))
|
|
4142
4222
|
})
|
|
4143
4223
|
);
|
|
@@ -4231,13 +4311,22 @@ async function genResource(rawName, options2) {
|
|
|
4231
4311
|
}
|
|
4232
4312
|
}
|
|
4233
4313
|
}
|
|
4314
|
+
const policyRelative = policyPath(name);
|
|
4315
|
+
const policyAbsolute = join17(appRoot, policyRelative);
|
|
4316
|
+
const wrotePolicy = !exists && !existsSync18(policyAbsolute);
|
|
4317
|
+
if (wrotePolicy) {
|
|
4318
|
+
mkdirSync8(dirname9(policyAbsolute), { recursive: true });
|
|
4319
|
+
writeFileSync11(policyAbsolute, renderPolicy(name, { read: ["admin"], create: ["admin"], update: ["admin"], delete: ["admin"] }));
|
|
4320
|
+
log2(`${pc11.green("create".padEnd(9))} ${policyRelative}`);
|
|
4321
|
+
}
|
|
4234
4322
|
let written;
|
|
4235
4323
|
let table;
|
|
4236
4324
|
try {
|
|
4237
4325
|
const all = await loadResources(appRoot);
|
|
4238
4326
|
table = all.find(({ resource }) => resource.name === name).resource.table;
|
|
4239
4327
|
ensureSupportFiles(appRoot, log2);
|
|
4240
|
-
|
|
4328
|
+
const policies = wrotePolicy ? await loadPolicies(appRoot) : void 0;
|
|
4329
|
+
written = applyPlan(appRoot, planFiles(all, policies, readStack(appRoot)), { force: options2.force });
|
|
4241
4330
|
logResults(written, log2);
|
|
4242
4331
|
} catch (error) {
|
|
4243
4332
|
const hint = exists ? "" : `
|
|
@@ -4265,7 +4354,8 @@ Next: ${pc11.bold("flare migrate")} to apply it locally.`);
|
|
|
4265
4354
|
}
|
|
4266
4355
|
}
|
|
4267
4356
|
const descriptor = { path: relativePath, status: exists ? "outdated" : "missing", outcome: exists ? "update" : "create" };
|
|
4268
|
-
|
|
4357
|
+
const policy = wrotePolicy ? [{ path: policyRelative, status: "missing", outcome: "create" }] : [];
|
|
4358
|
+
return { name, mode: exists ? "update" : "create", written: [descriptor, ...policy, ...written], migrations };
|
|
4269
4359
|
}
|
|
4270
4360
|
|
|
4271
4361
|
// src/commands/gen-endpoint.ts
|
|
@@ -5421,6 +5511,13 @@ Run it with ${pc19.bold(`flare seed ${name}`)}.`);
|
|
|
5421
5511
|
return relative3;
|
|
5422
5512
|
}
|
|
5423
5513
|
async function runPrismaSeeds(appRoot, files, log2) {
|
|
5514
|
+
if (!existsSync25(join25(appRoot, "lib", "generated", "prisma"))) {
|
|
5515
|
+
log2(pc19.dim("Generating the Prisma client first (migrate dev no longer does)\u2026"));
|
|
5516
|
+
const prisma = resolveBin(appRoot, "prisma", "prisma");
|
|
5517
|
+
if (await runNode([prisma, "generate"], appRoot) !== 0) {
|
|
5518
|
+
throw new Error("`prisma generate` failed, so there is no client for the seeds to use.");
|
|
5519
|
+
}
|
|
5520
|
+
}
|
|
5424
5521
|
const runner = join25(appRoot, ".flare-seed-runner.mts");
|
|
5425
5522
|
const script = [
|
|
5426
5523
|
`import "dotenv/config";`,
|
|
@@ -6228,7 +6325,7 @@ function createCli() {
|
|
|
6228
6325
|
const stack = currentStack();
|
|
6229
6326
|
const perStack = (cloudflare, next, either) => stack === "next" ? next : stack === "cloudflare" ? cloudflare : either;
|
|
6230
6327
|
const cloudflareOnly = (text) => stack === void 0 ? `${text} (Cloudflare only)` : text;
|
|
6231
|
-
cli.command("create <dir>", "Scaffold a new Flare app").option("--pm <manager>", "Package manager: pnpm, npm, yarn, or bun (default: detected)").option("--stack <name>", "Where it runs:
|
|
6328
|
+
cli.command("create <dir>", "Scaffold a new Flare app").option("--pm <manager>", "Package manager: pnpm, npm, yarn, or bun (default: detected)").option("--stack <name>", "Where it runs: next (default) or cloudflare").option("--theme <name>", "Look of the app: default, coral, amber, sky, mono or emerald").option("--auth <list>", "Sign-in methods: magic-link, email-otp, passkeys, 2fa-app, 2fa-email, all or none (default: all)").option("--auth-providers <list>", "Social sign-in, comma-separated: google, github, apple, microsoft").option("-y, --yes", "Don't ask: use the flags given and the defaults for the rest").option("--skip-install", "Write files without installing dependencies").example("flare create shop").example("flare create shop --stack next").example("flare create shop --theme mono --auth passkeys,2fa-app --auth-providers google,github --yes").action(
|
|
6232
6329
|
async (dir, options2) => {
|
|
6233
6330
|
const wanted = !options2.skipInstall;
|
|
6234
6331
|
let answers = { stack: options2.stack, theme: options2.theme, auth: options2.auth, authProviders: options2.authProviders };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flaredev/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.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.
|
|
44
|
+
"@flaredev/core": "0.11.0"
|
|
45
45
|
},
|
|
46
46
|
"engines": {
|
|
47
47
|
"node": ">=22"
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import Link from "next/link";
|
|
4
4
|
import { useRouter } from "next/navigation";
|
|
5
5
|
import { useMemo, useState, useTransition, type FormEvent, type MouseEvent } from "react";
|
|
6
|
-
import { createValidators, formValuesToInput, initialFormValues, issuesByField, storedFields, type ClientResource, type StoredField } from "@flaredev/core";
|
|
6
|
+
import { conditionHolds, createValidators, formValuesToInput, initialFormValues, issuesByField, storedFields, type ClientResource, type StoredField } from "@flaredev/core";
|
|
7
7
|
import { toast } from "sonner";
|
|
8
8
|
import { createRecordAction, updateRecordAction } from "@/app/dashboard/actions";
|
|
9
9
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
|
@@ -69,10 +69,21 @@ export function ResourceForm({ resource, mode, id, record, relations = {}, listH
|
|
|
69
69
|
const router = useRouter();
|
|
70
70
|
const validators = useMemo(() => createValidators(resource), [resource]);
|
|
71
71
|
const hidden = useMemo(() => new Set(omit ?? []), [omit]);
|
|
72
|
-
const fields = useMemo(() => storedFields(resource).filter(([key]) => !hidden.has(key)), [resource, hidden]);
|
|
73
|
-
const steps = useMemo(() => toSteps(fields), [fields]);
|
|
74
72
|
const [step, setStep] = useState(0);
|
|
75
73
|
const [values, setValues] = useState(() => initialFormValues(resource, record));
|
|
74
|
+
/**
|
|
75
|
+
* The fields on show. A conditional field appears only while its condition holds, so
|
|
76
|
+
* this is recomputed as the form is filled in: choose "recurring" and the billing cycle
|
|
77
|
+
* appears, choose "one-off" and it goes away again.
|
|
78
|
+
*
|
|
79
|
+
* The same condition decides what the server requires, so a field that is not on show
|
|
80
|
+
* is never one the save is about to fail on.
|
|
81
|
+
*/
|
|
82
|
+
const fields = useMemo(
|
|
83
|
+
() => storedFields(resource).filter(([key, def]) => !hidden.has(key) && conditionHolds(def.showWhen, values)),
|
|
84
|
+
[resource, hidden, values],
|
|
85
|
+
);
|
|
86
|
+
const steps = useMemo(() => toSteps(fields), [fields]);
|
|
76
87
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
|
77
88
|
const [pending, startTransition] = useTransition();
|
|
78
89
|
|
|
@@ -9,6 +9,7 @@ import { emailOTP } from "better-auth/plugins/email-otp";
|
|
|
9
9
|
import { magicLink } from "better-auth/plugins/magic-link";
|
|
10
10
|
import { twoFactor } from "better-auth/plugins/two-factor";
|
|
11
11
|
import { hashPassword, verifyPassword } from "@flaredev/core";
|
|
12
|
+
import { eq } from "drizzle-orm";
|
|
12
13
|
import { getDb, schema } from "@/db";
|
|
13
14
|
import { authConfig, type SocialProvider } from "./auth-config";
|
|
14
15
|
import { checkPassword, isBreachedPassword, PASSWORD_MAX_LENGTH } from "./password-rules";
|
|
@@ -107,6 +108,45 @@ async function guardPassword(path: string, body: Record<string, unknown> | undef
|
|
|
107
108
|
}
|
|
108
109
|
}
|
|
109
110
|
|
|
111
|
+
/**
|
|
112
|
+
* The first account to exist becomes the admin.
|
|
113
|
+
*
|
|
114
|
+
* Generated resources are admin-only, so without this a fresh app has a database, an API
|
|
115
|
+
* and a dashboard that nobody can reach — and the usual fix for that is to open
|
|
116
|
+
* everything up, which is how an admin panel ends up on the internet. The person running
|
|
117
|
+
* sign-up on an empty database is the person setting the app up.
|
|
118
|
+
*
|
|
119
|
+
* Done here rather than in `databaseHooks.user.create`, because the admin plugin already
|
|
120
|
+
* registers one of those (`{ role: defaultRole, ...user }`) and Better Auth does not merge
|
|
121
|
+
* the two — a second one is silently never called. This runs after the row exists and
|
|
122
|
+
* states the rule in SQL, which no plugin can quietly win.
|
|
123
|
+
*
|
|
124
|
+
* Everyone after them keeps the plugin's default role, and no role reaches an admin-only
|
|
125
|
+
* resource until you grant one with `flare user:role them@example.com admin`.
|
|
126
|
+
*
|
|
127
|
+
* Two people signing up in the same instant would both see two rows and neither would be
|
|
128
|
+
* promoted, leaving an app with no admin. It needs a brand-new app and millisecond timing,
|
|
129
|
+
* and `flare user:role you@example.com admin` fixes it, so this stays the simple rule
|
|
130
|
+
* rather than a transaction nobody can read.
|
|
131
|
+
*/
|
|
132
|
+
let adminSettled = false;
|
|
133
|
+
|
|
134
|
+
async function promoteFirstUser() {
|
|
135
|
+
if (adminSettled) return;
|
|
136
|
+
const db = getDb();
|
|
137
|
+
// Two rows is all it takes to answer "is there exactly one?".
|
|
138
|
+
const rows = await db.select({ id: schema.user.id, role: schema.user.role }).from(schema.user).limit(2);
|
|
139
|
+
if (rows.length !== 1) {
|
|
140
|
+
// Nobody yet: the next sign-up asks again. More than one: the question is settled.
|
|
141
|
+
adminSettled = rows.length > 1;
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (rows[0]!.role !== "admin") {
|
|
145
|
+
await db.update(schema.user).set({ role: "admin" }).where(eq(schema.user.id, rows[0]!.id));
|
|
146
|
+
}
|
|
147
|
+
adminSettled = true;
|
|
148
|
+
}
|
|
149
|
+
|
|
110
150
|
export const auth = betterAuth({
|
|
111
151
|
appName: "__APP_NAME__",
|
|
112
152
|
secret: env.BETTER_AUTH_SECRET,
|
|
@@ -148,6 +188,11 @@ export const auth = betterAuth({
|
|
|
148
188
|
if (disabledPath(ctx.path)) throw new APIError("NOT_FOUND", { message: "This sign-in method isn't enabled." });
|
|
149
189
|
await guardPassword(ctx.path, ctx.body as Record<string, unknown> | undefined);
|
|
150
190
|
}),
|
|
191
|
+
// Any route can be the one that creates the first user — a password sign-up, a magic
|
|
192
|
+
// link, a social callback — so this asks after all of them rather than guessing which.
|
|
193
|
+
after: createAuthMiddleware(async () => {
|
|
194
|
+
await promoteFirstUser();
|
|
195
|
+
}),
|
|
151
196
|
},
|
|
152
197
|
plugins: [
|
|
153
198
|
// Adds user.role (default "user") and admin APIs. The /admin area allows ADMIN_ROLES (lib/admin.ts).
|
|
@@ -108,9 +108,14 @@ export function drizzleRows(table: SQLiteTable, getDb: () => AnyDatabase): Resou
|
|
|
108
108
|
return (record as Row | undefined) ?? null;
|
|
109
109
|
},
|
|
110
110
|
|
|
111
|
-
async titles(ids, titleField) {
|
|
111
|
+
async titles(ids, titleField, owner) {
|
|
112
112
|
const titleColumn = columns[titleField] ?? idColumn;
|
|
113
|
-
const
|
|
113
|
+
const ownerColumn = owner ? columns[owner.field] : undefined;
|
|
114
|
+
// An owner naming a column this table does not have would widen the query rather
|
|
115
|
+
// than narrow it, so it answers nothing instead.
|
|
116
|
+
if (owner && !ownerColumn) return [];
|
|
117
|
+
const where = ownerColumn ? and(inArray(idColumn, ids), eq(ownerColumn, owner!.value)) : inArray(idColumn, ids);
|
|
118
|
+
const rows = await getDb().select({ id: idColumn, title: titleColumn }).from(table).where(where);
|
|
114
119
|
return rows as { id: string; title: unknown }[];
|
|
115
120
|
},
|
|
116
121
|
|
|
@@ -78,7 +78,11 @@ export interface ResourceRows {
|
|
|
78
78
|
/** How many rows match, giving up at `limit` so a huge table is never fully counted. */
|
|
79
79
|
countUpTo(query: Pick<RowsQuery, "search" | "filters" | "deleted">, limit: number): Promise<number>;
|
|
80
80
|
byId(id: string): Promise<Row | null>;
|
|
81
|
-
|
|
81
|
+
/**
|
|
82
|
+
* Title values for a set of ids. `owner`, when given, restricts the rows considered —
|
|
83
|
+
* a relation picker shown to a confined user must not name records they cannot see.
|
|
84
|
+
*/
|
|
85
|
+
titles(ids: string[], titleField: string, owner?: { field: string; value: string }): Promise<{ id: string; title: unknown }[]>;
|
|
82
86
|
insert(values: Row): Promise<Row>;
|
|
83
87
|
/** The saved row, or null when there was nothing with that id. */
|
|
84
88
|
update(id: string, values: Row): Promise<Row | null>;
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// `flare diff` shows how your copy differs from the version Flare ships, and
|
|
9
9
|
// `flare update` applies the parts you choose. Neither runs unless you ask.
|
|
10
10
|
|
|
11
|
-
import { columnName, fromMinorUnits, ownershipFilter, storedFields, toMinorUnits, type Policy, type Resource } from "@flaredev/core";
|
|
11
|
+
import { columnName, conditionHolds, fromMinorUnits, ownershipFilter, storedFields, toMinorUnits, type Policy, type Resource } from "@flaredev/core";
|
|
12
12
|
import { createValidators } from "@flaredev/core";
|
|
13
13
|
import { encodeCursor, isSearchable, parseListQuery, type QueryIssue } from "./query";
|
|
14
14
|
import type { ResourceRows, Row } from "./rows";
|
|
@@ -212,6 +212,34 @@ export function createResourceStore(options: ResourceStoreOptions) {
|
|
|
212
212
|
(def.kind === "int" || def.kind === "float") && def.format === "money" ? [{ key, currency: def.currency }] : [],
|
|
213
213
|
);
|
|
214
214
|
|
|
215
|
+
/**
|
|
216
|
+
* Fields that only apply when another field says so, e.g. a billing cycle that exists
|
|
217
|
+
* on a recurring project and not on a one-off one.
|
|
218
|
+
*
|
|
219
|
+
* The validator decides whether one is *required*; this decides whether it is *kept*.
|
|
220
|
+
* Without it, changing a project from recurring to one-off leaves last month's billing
|
|
221
|
+
* cycle sitting in the row — the form stopped showing the input, so nothing ever sent a
|
|
222
|
+
* value to overwrite it, and the record quietly disagrees with itself.
|
|
223
|
+
*/
|
|
224
|
+
const conditionalFields = fields.flatMap(([key, def]) => (def.showWhen ? [{ key, condition: def.showWhen }] : []));
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Null the fields that no longer apply.
|
|
228
|
+
*
|
|
229
|
+
* `record` is what the condition is judged against: the whole record as it will be once
|
|
230
|
+
* this write lands, which on a PATCH means the stored row with the patch laid over it.
|
|
231
|
+
* Judging the patch alone would read a field it never mentioned as absent and clear a
|
|
232
|
+
* value that was meant to stay.
|
|
233
|
+
*/
|
|
234
|
+
function clearInapplicable(values: Row, record: Row): Row {
|
|
235
|
+
if (conditionalFields.length === 0) return values;
|
|
236
|
+
const out = { ...values };
|
|
237
|
+
for (const { key, condition } of conditionalFields) {
|
|
238
|
+
if (!conditionHolds(condition, record)) out[key] = null;
|
|
239
|
+
}
|
|
240
|
+
return out;
|
|
241
|
+
}
|
|
242
|
+
|
|
215
243
|
/** Amounts as the database stores them: 19.99 -> 1999. */
|
|
216
244
|
function toStored(values: Row): Row {
|
|
217
245
|
if (moneyFields.length === 0) return values;
|
|
@@ -405,16 +433,19 @@ export function createResourceStore(options: ResourceStoreOptions) {
|
|
|
405
433
|
/**
|
|
406
434
|
* Title-field values for a set of ids (for showing relations). Missing ids are omitted.
|
|
407
435
|
*
|
|
408
|
-
*
|
|
409
|
-
*
|
|
410
|
-
*
|
|
411
|
-
*
|
|
412
|
-
*
|
|
436
|
+
* Confined by ownership, like every other read: a relation picker shown to a user who
|
|
437
|
+
* only sees their own rows must not name anybody else's. An id they may not see is
|
|
438
|
+
* simply absent from the result, which the caller already handles — missing ids are
|
|
439
|
+
* omitted either way, so a title they are not entitled to looks the same as one that
|
|
440
|
+
* was deleted.
|
|
413
441
|
*/
|
|
414
442
|
async titles(ids: string[]): Promise<Record<string, string>> {
|
|
415
443
|
const unique = [...new Set(ids.filter(Boolean))];
|
|
416
444
|
if (unique.length === 0) return {};
|
|
417
|
-
const
|
|
445
|
+
const limit = await confine();
|
|
446
|
+
// Not signed in where sign-in is required: no titles rather than everyone's.
|
|
447
|
+
if (isFailure(limit)) return {};
|
|
448
|
+
const found = await rows.titles(unique, resource.titleField, limit ?? undefined);
|
|
418
449
|
return Object.fromEntries(found.map((row) => [row.id, row.title == null ? row.id : String(row.title)]));
|
|
419
450
|
},
|
|
420
451
|
|
|
@@ -436,7 +467,8 @@ export function createResourceStore(options: ResourceStoreOptions) {
|
|
|
436
467
|
if (!result.success) return invalid(result.error.issues);
|
|
437
468
|
const input = result.data as Record<string, unknown>;
|
|
438
469
|
const now = new Date();
|
|
439
|
-
const
|
|
470
|
+
const fresh = { ...input, id: crypto.randomUUID(), createdAt: now, updatedAt: now };
|
|
471
|
+
const record = await rows.insert(toStored(clearInapplicable(fresh, fresh)));
|
|
440
472
|
const saved = withComputed(record);
|
|
441
473
|
await hooks.afterCreate?.(saved, context);
|
|
442
474
|
return { ok: true, data: saved };
|
|
@@ -452,7 +484,10 @@ export function createResourceStore(options: ResourceStoreOptions) {
|
|
|
452
484
|
|
|
453
485
|
// A confined update has to read the row first: `rows.update(id, …)` takes an id
|
|
454
486
|
// and nothing else, so this is where "is it yours" is answered.
|
|
455
|
-
|
|
487
|
+
// Conditional fields join this list: deciding whether one still applies needs the
|
|
488
|
+
// whole record, and a PATCH on its own may not mention the field it depends on.
|
|
489
|
+
const needsCurrent = limit || hooks.beforeUpdate || hooks.afterUpdate || conditionalFields.length > 0;
|
|
490
|
+
const current = needsCurrent ? await rows.byId(id) : null;
|
|
456
491
|
if (limit) {
|
|
457
492
|
if (!current) return notFound();
|
|
458
493
|
if (!owns(current, limit)) return notFound();
|
|
@@ -476,7 +511,8 @@ export function createResourceStore(options: ResourceStoreOptions) {
|
|
|
476
511
|
const result = validators.update.safeParse(supplied);
|
|
477
512
|
if (!result.success) return invalid(result.error.issues);
|
|
478
513
|
const input = result.data as Record<string, unknown>;
|
|
479
|
-
const
|
|
514
|
+
const merged = { ...((current ?? {}) as Row), ...input };
|
|
515
|
+
const record = await rows.update(id, toStored(clearInapplicable({ ...input, updatedAt: new Date() }, merged)));
|
|
480
516
|
if (!record) return notFound();
|
|
481
517
|
const saved = withComputed(record);
|
|
482
518
|
await hooks.afterUpdate?.(saved, { ...context, previous: current });
|
|
@@ -505,7 +541,7 @@ export function createResourceStore(options: ResourceStoreOptions) {
|
|
|
505
541
|
// one of them, and a replace would quietly orphan the row it just saved.
|
|
506
542
|
if (limit) values[limit.field] = limit.value;
|
|
507
543
|
|
|
508
|
-
const record = await rows.update(id, toStored(values));
|
|
544
|
+
const record = await rows.update(id, toStored(clearInapplicable(values, values)));
|
|
509
545
|
return record ? { ok: true, data: withComputed(record) } : notFound();
|
|
510
546
|
});
|
|
511
547
|
},
|