create-cronus-stack 0.6.0 → 0.6.2

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/scaffold.js CHANGED
@@ -41,6 +41,79 @@ function cronusUiPaths(config) {
41
41
  function usesCronusUi(config) {
42
42
  return single(config, "ui") === "ui-cronus";
43
43
  }
44
+ const SQL_DATABASES = new Set(["db-sqlite", "db-postgres", "db-mysql"]);
45
+ const HOSTED_DB_SETUPS = new Set([
46
+ "dbsetup-turso",
47
+ "dbsetup-neon",
48
+ "dbsetup-supabase",
49
+ "dbsetup-planetscale",
50
+ "dbsetup-d1",
51
+ "dbsetup-atlas",
52
+ ]);
53
+ function sqlDialect(config) {
54
+ switch (single(config, "database")) {
55
+ case "db-sqlite":
56
+ return "sqlite";
57
+ case "db-postgres":
58
+ return "postgresql";
59
+ case "db-mysql":
60
+ return "mysql";
61
+ default:
62
+ return undefined;
63
+ }
64
+ }
65
+ function emitsDrizzle(config) {
66
+ return (single(config, "web") === "web-next" &&
67
+ single(config, "orm") === "orm-drizzle" &&
68
+ SQL_DATABASES.has(single(config, "database") ?? ""));
69
+ }
70
+ function emitsBetterAuth(config) {
71
+ return emitsDrizzle(config) && single(config, "auth") === "auth-better-auth";
72
+ }
73
+ function usesImportAlias(config) {
74
+ return single(config, "importAlias") === "import-alias";
75
+ }
76
+ function sourceRoot(config) {
77
+ return single(config, "structure") === "structure-root" ? "" : "src/";
78
+ }
79
+ function dbDir(config) {
80
+ return `${sourceRoot(config)}db`;
81
+ }
82
+ function libDir(config) {
83
+ return `${sourceRoot(config)}lib`;
84
+ }
85
+ function defaultDatabaseUrl(config) {
86
+ switch (single(config, "database")) {
87
+ case "db-sqlite":
88
+ return "file:./data/app.db";
89
+ case "db-postgres":
90
+ return "postgres://postgres:postgres@localhost:5432/app";
91
+ case "db-mysql":
92
+ return "mysql://root:password@localhost:3306/app";
93
+ default:
94
+ return undefined;
95
+ }
96
+ }
97
+ function betterAuthProvider(config) {
98
+ switch (single(config, "database")) {
99
+ case "db-postgres":
100
+ return "pg";
101
+ case "db-mysql":
102
+ return "mysql";
103
+ default:
104
+ return "sqlite";
105
+ }
106
+ }
107
+ function dbModuleImport(config, subpath) {
108
+ const target = subpath ? `db/${subpath}` : "db";
109
+ return usesImportAlias(config) ? `@/${target}` : `../${target}`;
110
+ }
111
+ function authModuleImport(config) {
112
+ return usesImportAlias(config) ? "@/lib/auth" : "../../../../lib/auth";
113
+ }
114
+ function scriptCommand(pm, script) {
115
+ return pm === "npm" ? `npm run ${script}` : `${pm} ${script}`;
116
+ }
44
117
  function packageJson(projectName, config) {
45
118
  const isNext = single(config, "web") === "web-next";
46
119
  const isCronusUi = usesCronusUi(config);
@@ -70,6 +143,25 @@ function packageJson(projectName, config) {
70
143
  add(devDeps, "@commitlint/cli", "^20.2.0");
71
144
  add(devDeps, "@commitlint/config-conventional", "^20.2.0");
72
145
  }
146
+ if (emitsDrizzle(config)) {
147
+ add(deps, "drizzle-orm", "^0.45.2");
148
+ add(devDeps, "drizzle-kit", "^0.31.10");
149
+ switch (single(config, "database")) {
150
+ case "db-sqlite":
151
+ add(deps, "better-sqlite3", "^12.0.0");
152
+ add(devDeps, "@types/better-sqlite3", "^9.6.0");
153
+ break;
154
+ case "db-postgres":
155
+ add(deps, "postgres", "^3.4.9");
156
+ break;
157
+ case "db-mysql":
158
+ add(deps, "mysql2", "^3.24.2");
159
+ break;
160
+ }
161
+ }
162
+ if (emitsBetterAuth(config)) {
163
+ add(deps, "better-auth", "^1.7.2");
164
+ }
73
165
  const scripts = isNext
74
166
  ? {
75
167
  dev: "next dev",
@@ -86,7 +178,12 @@ function packageJson(projectName, config) {
86
178
  scripts.lint = "biome check .";
87
179
  scripts.format = "biome format --write .";
88
180
  }
89
- if (single(config, "database") !== "db-none") {
181
+ if (emitsDrizzle(config)) {
182
+ scripts["db:push"] = "drizzle-kit push";
183
+ scripts["db:generate"] = "drizzle-kit generate";
184
+ scripts["db:studio"] = "drizzle-kit studio";
185
+ }
186
+ else if (single(config, "database") !== "db-none") {
90
187
  scripts["db:push"] = 'echo "Configure the selected database/ORM before syncing schema."';
91
188
  }
92
189
  return `${JSON.stringify({
@@ -214,19 +311,95 @@ export default function RootLayout({ children }: { children: ReactNode }) {
214
311
  }
215
312
  `;
216
313
  }
217
- function pageTsx() {
314
+ function fromAppImport(config, target) {
315
+ return usesImportAlias(config) ? `@/${target}` : `../${target}`;
316
+ }
317
+ function pageStatusCopy(config) {
318
+ if (emitsBetterAuth(config) && emitsDrizzle(config)) {
319
+ return ` {session ? (
320
+ <p className="max-w-prose text-fg-secondary">
321
+ Signed in as {session.user?.email}. {itemCount} items in the active workspace.
322
+ </p>
323
+ ) : (
324
+ <p className="max-w-prose text-fg-secondary">
325
+ Sign in required on this stack. Middleware redirects unsigned-in visitors to /login.
326
+ </p>
327
+ )}`;
328
+ }
329
+ if (emitsDrizzle(config)) {
330
+ return ` <p className="max-w-prose text-fg-secondary">
331
+ {itemCount} items in the database. Read KICKOFF.md before changing frameworks,
332
+ databases, auth, payments, or design-system rules.
333
+ </p>`;
334
+ }
335
+ return ` <p className="max-w-prose text-fg-secondary">
336
+ This app was scaffolded from the Cronus Stack Builder. Read KICKOFF.md before changing
337
+ frameworks, databases, auth, payments, or design-system rules.
338
+ </p>`;
339
+ }
340
+ function pageTsx(config) {
341
+ const withDrizzle = emitsDrizzle(config);
342
+ const withAuth = emitsBetterAuth(config);
343
+ const extraImports = [
344
+ withDrizzle && withAuth
345
+ ? 'import { count, eq } from "drizzle-orm";'
346
+ : withDrizzle
347
+ ? 'import { count } from "drizzle-orm";'
348
+ : undefined,
349
+ withAuth ? 'import { headers } from "next/headers";' : undefined,
350
+ withDrizzle ? `import { db } from "${fromAppImport(config, "db")}";` : undefined,
351
+ withDrizzle && withAuth
352
+ ? `import { items, member } from "${fromAppImport(config, "db/schema")}";`
353
+ : withDrizzle
354
+ ? `import { items } from "${fromAppImport(config, "db/schema")}";`
355
+ : undefined,
356
+ withAuth ? `import { auth } from "${fromAppImport(config, "lib/auth")}";` : undefined,
357
+ ]
358
+ .filter((line) => line !== undefined)
359
+ .join("\n");
360
+ const asyncKw = withDrizzle || withAuth ? "async " : "";
361
+ let loader = "";
362
+ if (withDrizzle && withAuth) {
363
+ loader = ` const session = await auth.api.getSession({ headers: await headers() });
364
+ let orgId = session?.session?.activeOrganizationId ?? null;
365
+ if (!orgId && session?.user?.id) {
366
+ const [row] = await db
367
+ .select({ organizationId: member.organizationId })
368
+ .from(member)
369
+ .where(eq(member.userId, session.user.id))
370
+ .limit(1);
371
+ orgId = row?.organizationId ?? null;
372
+ }
373
+ const [itemRow] = orgId
374
+ ? await db.select({ value: count() }).from(items).where(eq(items.workspaceId, orgId))
375
+ : [{ value: 0 }];
376
+ const itemCount = new Intl.NumberFormat("en-US").format(Number(itemRow?.value ?? 0));
377
+
378
+ `;
379
+ }
380
+ else if (withDrizzle) {
381
+ loader = ` const [itemRow] = await db.select({ value: count() }).from(items);
382
+ const itemCount = new Intl.NumberFormat("en-US").format(Number(itemRow?.value ?? 0));
383
+
384
+ `;
385
+ }
386
+ else if (withAuth) {
387
+ loader = ` const session = await auth.api.getSession({ headers: await headers() });
388
+
389
+ `;
390
+ }
218
391
  return `import { Badge } from "@cronus-ui/ui/badge";
219
392
  import { Button } from "@cronus-ui/ui/button";
220
393
  import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@cronus-ui/ui/card";
221
-
394
+ ${extraImports ? `${extraImports}\n` : ""}
222
395
  const metrics = [
223
396
  { label: "Revenue", value: "R$ 48.2k" },
224
397
  { label: "Active users", value: "2,318" },
225
398
  { label: "NPS", value: "72" },
226
399
  ];
227
400
 
228
- export default function Page() {
229
- return (
401
+ export default ${asyncKw}function Page() {
402
+ ${loader} return (
230
403
  <main className="mx-auto flex min-h-screen w-full max-w-5xl flex-col gap-8 px-6 py-12">
231
404
  <header className="flex flex-col gap-4">
232
405
  <Badge variant="primary" className="w-fit">
@@ -234,10 +407,7 @@ export default function Page() {
234
407
  </Badge>
235
408
  <div className="flex flex-col gap-3">
236
409
  <h1 className="text-4xl font-semibold tracking-tight text-fg">Your stack is ready</h1>
237
- <p className="max-w-prose text-fg-secondary">
238
- This app was scaffolded from the Cronus Stack Builder. Read KICKOFF.md before changing
239
- frameworks, databases, auth, payments, or design-system rules.
240
- </p>
410
+ ${pageStatusCopy(config)}
241
411
  </div>
242
412
  <div className="flex flex-wrap gap-3">
243
413
  <Button variant="primary">Start building</Button>
@@ -303,8 +473,12 @@ function basicIndex(projectName) {
303
473
  }
304
474
  function readme(projectName, config, unsupported) {
305
475
  const pm = packageManagerFromConfig(config);
306
- const dev = pm === "npm" ? "npm run dev" : `${pm} dev`;
307
476
  const install = pm === "yarn" ? "yarn" : `${pm} install`;
477
+ const runLines = [install];
478
+ if (emitsDrizzle(config) && single(config, "database") === "db-sqlite") {
479
+ runLines.push(scriptCommand(pm, "db:push"));
480
+ }
481
+ runLines.push(scriptCommand(pm, "dev"));
308
482
  return `# ${projectName}
309
483
 
310
484
  Generated by \`create-cronus-stack\`.
@@ -315,8 +489,7 @@ conventions, AI capabilities, guardrails, and Definition of Done.
315
489
  ## Run
316
490
 
317
491
  \`\`\`sh
318
- ${install}
319
- ${dev}
492
+ ${runLines.join("\n")}
320
493
  \`\`\`
321
494
 
322
495
  ## Generated artifacts
@@ -330,16 +503,42 @@ ${unsupported.length ? `## Manual follow-up\n\n${unsupported.map((item) => `- ${
330
503
  }
331
504
  function envExample(config) {
332
505
  const lines = [];
333
- if (single(config, "database") !== "db-none")
334
- lines.push("DATABASE_URL=");
335
- if (single(config, "auth") !== "auth-none")
506
+ if (single(config, "database") !== "db-none") {
507
+ const url = emitsDrizzle(config) ? (defaultDatabaseUrl(config) ?? "") : "";
508
+ lines.push(`DATABASE_URL=${url}`);
509
+ }
510
+ if (emitsBetterAuth(config)) {
511
+ lines.push("BETTER_AUTH_SECRET=change-me-to-a-32-character-secret");
512
+ lines.push("BETTER_AUTH_URL=http://localhost:3000");
513
+ }
514
+ else if (single(config, "auth") !== "auth-none") {
336
515
  lines.push("AUTH_SECRET=");
516
+ }
337
517
  if (single(config, "payments") !== "pay-none") {
338
518
  lines.push("PAYMENTS_SECRET_KEY=");
339
519
  lines.push("PAYMENTS_WEBHOOK_SECRET=");
340
520
  }
341
521
  return lines.length ? `${lines.join("\n")}\n` : undefined;
342
522
  }
523
+ function gitignore(config) {
524
+ const lines = ["node_modules", ".next", "dist", ".env*", "!.env.example", ".DS_Store"];
525
+ if (emitsDrizzle(config)) {
526
+ lines.push("*.db", "data/", "drizzle/");
527
+ }
528
+ return `${lines.join("\n")}\n`;
529
+ }
530
+ function nextConfigMjs(config) {
531
+ const body = emitsDrizzle(config) && single(config, "database") === "db-sqlite"
532
+ ? `{
533
+ serverExternalPackages: ["better-sqlite3"],
534
+ }`
535
+ : "{}";
536
+ return `/** @type {import('next').NextConfig} */
537
+ const nextConfig = ${body};
538
+
539
+ export default nextConfig;
540
+ `;
541
+ }
343
542
  function assistantIds(config) {
344
543
  const picked = new Set();
345
544
  for (const id of multi(config, "assistants")) {
@@ -354,6 +553,636 @@ function assistantIds(config) {
354
553
  }
355
554
  return [...picked];
356
555
  }
556
+ const CATALOG_SKILL_TO_KIT = {
557
+ "skill-ui-add": "ui-add",
558
+ "skill-theme": "theme",
559
+ "skill-compose": "compose",
560
+ "skill-upgrade": "upgrade",
561
+ "skill-code-review": "code-review",
562
+ "skill-ship-pr": "ship-pr",
563
+ "skill-evidence-check": "evidence-check",
564
+ };
565
+ /** Catalog skill ids → AI Kit skills. Empty selection keeps the kit default. */
566
+ function kitSkillsFromConfig(config) {
567
+ const selected = multi(config, "skills");
568
+ if (selected.length === 0)
569
+ return undefined;
570
+ const mapped = [];
571
+ for (const id of selected) {
572
+ const skill = CATALOG_SKILL_TO_KIT[id];
573
+ if (skill)
574
+ mapped.push(skill);
575
+ }
576
+ return mapped;
577
+ }
578
+ function drizzleConfig(config) {
579
+ const dialect = sqlDialect(config) ?? "sqlite";
580
+ const fallback = defaultDatabaseUrl(config) ?? "file:./data/app.db";
581
+ if (dialect === "sqlite") {
582
+ return `import { mkdirSync } from "node:fs";
583
+ import { dirname } from "node:path";
584
+ import { defineConfig } from "drizzle-kit";
585
+
586
+ const url = process.env.DATABASE_URL ?? "${fallback}";
587
+ const fileFromUrl = url.startsWith("file:") ? url.slice("file:".length) : url;
588
+ mkdirSync(dirname(fileFromUrl) || ".", { recursive: true });
589
+
590
+ export default defineConfig({
591
+ dialect: "sqlite",
592
+ schema: "./${dbDir(config)}/schema.ts",
593
+ out: "./drizzle",
594
+ dbCredentials: { url },
595
+ });
596
+ `;
597
+ }
598
+ return `import { defineConfig } from "drizzle-kit";
599
+
600
+ export default defineConfig({
601
+ dialect: "${dialect}",
602
+ schema: "./${dbDir(config)}/schema.ts",
603
+ out: "./drizzle",
604
+ dbCredentials: { url: process.env.DATABASE_URL ?? "${fallback}" },
605
+ });
606
+ `;
607
+ }
608
+ function sqliteAuthTables() {
609
+ return `
610
+ export const user = sqliteTable("user", {
611
+ id: text("id").primaryKey(),
612
+ name: text("name").notNull(),
613
+ email: text("email").notNull().unique(),
614
+ emailVerified: integer("email_verified", { mode: "boolean" }).notNull(),
615
+ image: text("image"),
616
+ createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
617
+ updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
618
+ });
619
+
620
+ export const session = sqliteTable("session", {
621
+ id: text("id").primaryKey(),
622
+ expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
623
+ token: text("token").notNull().unique(),
624
+ createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
625
+ updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
626
+ ipAddress: text("ip_address"),
627
+ userAgent: text("user_agent"),
628
+ activeOrganizationId: text("active_organization_id"),
629
+ userId: text("user_id")
630
+ .notNull()
631
+ .references(() => user.id, { onDelete: "cascade" }),
632
+ });
633
+
634
+ export const account = sqliteTable("account", {
635
+ id: text("id").primaryKey(),
636
+ issuer: text("issuer").notNull(),
637
+ accountId: text("account_id").notNull(),
638
+ providerId: text("provider_id").notNull(),
639
+ userId: text("user_id")
640
+ .notNull()
641
+ .references(() => user.id, { onDelete: "cascade" }),
642
+ accessToken: text("access_token"),
643
+ refreshToken: text("refresh_token"),
644
+ idToken: text("id_token"),
645
+ accessTokenExpiresAt: integer("access_token_expires_at", { mode: "timestamp" }),
646
+ refreshTokenExpiresAt: integer("refresh_token_expires_at", { mode: "timestamp" }),
647
+ scope: text("scope"),
648
+ password: text("password"),
649
+ createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
650
+ updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
651
+ });
652
+
653
+ export const verification = sqliteTable("verification", {
654
+ id: text("id").primaryKey(),
655
+ identifier: text("identifier").notNull(),
656
+ value: text("value").notNull(),
657
+ expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
658
+ createdAt: integer("created_at", { mode: "timestamp" }),
659
+ updatedAt: integer("updated_at", { mode: "timestamp" }),
660
+ });
661
+
662
+ export const organization = sqliteTable("organization", {
663
+ id: text("id").primaryKey(),
664
+ name: text("name").notNull(),
665
+ slug: text("slug").notNull().unique(),
666
+ logo: text("logo"),
667
+ metadata: text("metadata"),
668
+ createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
669
+ });
670
+
671
+ export const member = sqliteTable("member", {
672
+ id: text("id").primaryKey(),
673
+ organizationId: text("organization_id")
674
+ .notNull()
675
+ .references(() => organization.id, { onDelete: "cascade" }),
676
+ userId: text("user_id")
677
+ .notNull()
678
+ .references(() => user.id, { onDelete: "cascade" }),
679
+ role: text("role").notNull(),
680
+ createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
681
+ });
682
+
683
+ export const invitation = sqliteTable("invitation", {
684
+ id: text("id").primaryKey(),
685
+ organizationId: text("organization_id")
686
+ .notNull()
687
+ .references(() => organization.id, { onDelete: "cascade" }),
688
+ email: text("email").notNull(),
689
+ role: text("role"),
690
+ status: text("status").notNull(),
691
+ expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
692
+ createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
693
+ inviterId: text("inviter_id")
694
+ .notNull()
695
+ .references(() => user.id, { onDelete: "cascade" }),
696
+ });
697
+ `;
698
+ }
699
+ function pgAuthTables() {
700
+ return `
701
+ export const user = pgTable("user", {
702
+ id: text("id").primaryKey(),
703
+ name: text("name").notNull(),
704
+ email: text("email").notNull().unique(),
705
+ emailVerified: boolean("email_verified").notNull(),
706
+ image: text("image"),
707
+ createdAt: timestamp("created_at").notNull(),
708
+ updatedAt: timestamp("updated_at").notNull(),
709
+ });
710
+
711
+ export const session = pgTable("session", {
712
+ id: text("id").primaryKey(),
713
+ expiresAt: timestamp("expires_at").notNull(),
714
+ token: text("token").notNull().unique(),
715
+ createdAt: timestamp("created_at").notNull(),
716
+ updatedAt: timestamp("updated_at").notNull(),
717
+ ipAddress: text("ip_address"),
718
+ userAgent: text("user_agent"),
719
+ activeOrganizationId: text("active_organization_id"),
720
+ userId: text("user_id")
721
+ .notNull()
722
+ .references(() => user.id, { onDelete: "cascade" }),
723
+ });
724
+
725
+ export const account = pgTable("account", {
726
+ id: text("id").primaryKey(),
727
+ issuer: text("issuer").notNull(),
728
+ accountId: text("account_id").notNull(),
729
+ providerId: text("provider_id").notNull(),
730
+ userId: text("user_id")
731
+ .notNull()
732
+ .references(() => user.id, { onDelete: "cascade" }),
733
+ accessToken: text("access_token"),
734
+ refreshToken: text("refresh_token"),
735
+ idToken: text("id_token"),
736
+ accessTokenExpiresAt: timestamp("access_token_expires_at"),
737
+ refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
738
+ scope: text("scope"),
739
+ password: text("password"),
740
+ createdAt: timestamp("created_at").notNull(),
741
+ updatedAt: timestamp("updated_at").notNull(),
742
+ });
743
+
744
+ export const verification = pgTable("verification", {
745
+ id: text("id").primaryKey(),
746
+ identifier: text("identifier").notNull(),
747
+ value: text("value").notNull(),
748
+ expiresAt: timestamp("expires_at").notNull(),
749
+ createdAt: timestamp("created_at"),
750
+ updatedAt: timestamp("updated_at"),
751
+ });
752
+
753
+ export const organization = pgTable("organization", {
754
+ id: text("id").primaryKey(),
755
+ name: text("name").notNull(),
756
+ slug: text("slug").notNull().unique(),
757
+ logo: text("logo"),
758
+ metadata: text("metadata"),
759
+ createdAt: timestamp("created_at").notNull(),
760
+ });
761
+
762
+ export const member = pgTable("member", {
763
+ id: text("id").primaryKey(),
764
+ organizationId: text("organization_id")
765
+ .notNull()
766
+ .references(() => organization.id, { onDelete: "cascade" }),
767
+ userId: text("user_id")
768
+ .notNull()
769
+ .references(() => user.id, { onDelete: "cascade" }),
770
+ role: text("role").notNull(),
771
+ createdAt: timestamp("created_at").notNull(),
772
+ });
773
+
774
+ export const invitation = pgTable("invitation", {
775
+ id: text("id").primaryKey(),
776
+ organizationId: text("organization_id")
777
+ .notNull()
778
+ .references(() => organization.id, { onDelete: "cascade" }),
779
+ email: text("email").notNull(),
780
+ role: text("role"),
781
+ status: text("status").notNull(),
782
+ expiresAt: timestamp("expires_at").notNull(),
783
+ createdAt: timestamp("created_at").notNull(),
784
+ inviterId: text("inviter_id")
785
+ .notNull()
786
+ .references(() => user.id, { onDelete: "cascade" }),
787
+ });
788
+ `;
789
+ }
790
+ function mysqlAuthTables() {
791
+ return `
792
+ export const user = mysqlTable("user", {
793
+ id: varchar("id", { length: 36 }).primaryKey(),
794
+ name: varchar("name", { length: 255 }).notNull(),
795
+ email: varchar("email", { length: 255 }).notNull().unique(),
796
+ emailVerified: boolean("email_verified").notNull(),
797
+ image: text("image"),
798
+ createdAt: timestamp("created_at").notNull(),
799
+ updatedAt: timestamp("updated_at").notNull(),
800
+ });
801
+
802
+ export const session = mysqlTable("session", {
803
+ id: varchar("id", { length: 36 }).primaryKey(),
804
+ expiresAt: timestamp("expires_at").notNull(),
805
+ token: varchar("token", { length: 255 }).notNull().unique(),
806
+ createdAt: timestamp("created_at").notNull(),
807
+ updatedAt: timestamp("updated_at").notNull(),
808
+ ipAddress: text("ip_address"),
809
+ userAgent: text("user_agent"),
810
+ activeOrganizationId: varchar("active_organization_id", { length: 36 }),
811
+ userId: varchar("user_id", { length: 36 })
812
+ .notNull()
813
+ .references(() => user.id, { onDelete: "cascade" }),
814
+ });
815
+
816
+ export const account = mysqlTable("account", {
817
+ id: varchar("id", { length: 36 }).primaryKey(),
818
+ issuer: varchar("issuer", { length: 255 }).notNull(),
819
+ accountId: varchar("account_id", { length: 255 }).notNull(),
820
+ providerId: varchar("provider_id", { length: 255 }).notNull(),
821
+ userId: varchar("user_id", { length: 36 })
822
+ .notNull()
823
+ .references(() => user.id, { onDelete: "cascade" }),
824
+ accessToken: text("access_token"),
825
+ refreshToken: text("refresh_token"),
826
+ idToken: text("id_token"),
827
+ accessTokenExpiresAt: timestamp("access_token_expires_at"),
828
+ refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
829
+ scope: text("scope"),
830
+ password: text("password"),
831
+ createdAt: timestamp("created_at").notNull(),
832
+ updatedAt: timestamp("updated_at").notNull(),
833
+ });
834
+
835
+ export const verification = mysqlTable("verification", {
836
+ id: varchar("id", { length: 36 }).primaryKey(),
837
+ identifier: varchar("identifier", { length: 255 }).notNull(),
838
+ value: text("value").notNull(),
839
+ expiresAt: timestamp("expires_at").notNull(),
840
+ createdAt: timestamp("created_at"),
841
+ updatedAt: timestamp("updated_at"),
842
+ });
843
+
844
+ export const organization = mysqlTable("organization", {
845
+ id: varchar("id", { length: 36 }).primaryKey(),
846
+ name: varchar("name", { length: 255 }).notNull(),
847
+ slug: varchar("slug", { length: 255 }).notNull().unique(),
848
+ logo: text("logo"),
849
+ metadata: text("metadata"),
850
+ createdAt: timestamp("created_at").notNull(),
851
+ });
852
+
853
+ export const member = mysqlTable("member", {
854
+ id: varchar("id", { length: 36 }).primaryKey(),
855
+ organizationId: varchar("organization_id", { length: 36 })
856
+ .notNull()
857
+ .references(() => organization.id, { onDelete: "cascade" }),
858
+ userId: varchar("user_id", { length: 36 })
859
+ .notNull()
860
+ .references(() => user.id, { onDelete: "cascade" }),
861
+ role: varchar("role", { length: 255 }).notNull(),
862
+ createdAt: timestamp("created_at").notNull(),
863
+ });
864
+
865
+ export const invitation = mysqlTable("invitation", {
866
+ id: varchar("id", { length: 36 }).primaryKey(),
867
+ organizationId: varchar("organization_id", { length: 36 })
868
+ .notNull()
869
+ .references(() => organization.id, { onDelete: "cascade" }),
870
+ email: varchar("email", { length: 255 }).notNull(),
871
+ role: varchar("role", { length: 255 }),
872
+ status: varchar("status", { length: 255 }).notNull(),
873
+ expiresAt: timestamp("expires_at").notNull(),
874
+ createdAt: timestamp("created_at").notNull(),
875
+ inviterId: varchar("inviter_id", { length: 36 })
876
+ .notNull()
877
+ .references(() => user.id, { onDelete: "cascade" }),
878
+ });
879
+ `;
880
+ }
881
+ function drizzleSchema(config) {
882
+ const withAuth = emitsBetterAuth(config);
883
+ switch (sqlDialect(config)) {
884
+ case "postgresql": {
885
+ const imports = withAuth
886
+ ? 'import { boolean, integer, pgTable, text, timestamp } from "drizzle-orm/pg-core";'
887
+ : 'import { integer, pgTable, text } from "drizzle-orm/pg-core";';
888
+ const workspaceCol = withAuth
889
+ ? `\n workspaceId: text("workspace_id").references(() => organization.id, { onDelete: "cascade" }),`
890
+ : "";
891
+ return `${imports}
892
+ ${withAuth ? pgAuthTables() : ""}
893
+ export const items = pgTable("items", {
894
+ id: integer("id").primaryKey().generatedAlwaysAsIdentity(),
895
+ title: text("title").notNull(),${workspaceCol}
896
+ });
897
+ `;
898
+ }
899
+ case "mysql": {
900
+ const imports = withAuth
901
+ ? 'import { boolean, int, mysqlTable, text, timestamp, varchar } from "drizzle-orm/mysql-core";'
902
+ : 'import { int, mysqlTable, varchar } from "drizzle-orm/mysql-core";';
903
+ const workspaceCol = withAuth
904
+ ? `\n workspaceId: varchar("workspace_id", { length: 36 }).references(() => organization.id, { onDelete: "cascade" }),`
905
+ : "";
906
+ return `${imports}
907
+ ${withAuth ? mysqlAuthTables() : ""}
908
+ export const items = mysqlTable("items", {
909
+ id: int("id").autoincrement().primaryKey(),
910
+ title: varchar("title", { length: 255 }).notNull(),${workspaceCol}
911
+ });
912
+ `;
913
+ }
914
+ default: {
915
+ const workspaceCol = withAuth
916
+ ? `\n workspaceId: text("workspace_id").references(() => organization.id, { onDelete: "cascade" }),`
917
+ : "";
918
+ return `import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
919
+ ${withAuth ? sqliteAuthTables() : ""}
920
+ export const items = sqliteTable("items", {
921
+ id: integer("id").primaryKey({ autoIncrement: true }),
922
+ title: text("title").notNull(),${workspaceCol}
923
+ });
924
+ `;
925
+ }
926
+ }
927
+ }
928
+ function drizzleClient(config) {
929
+ const fallback = defaultDatabaseUrl(config) ?? "file:./data/app.db";
930
+ switch (sqlDialect(config)) {
931
+ case "postgresql":
932
+ return `import { drizzle } from "drizzle-orm/postgres-js";
933
+ import postgres from "postgres";
934
+ import * as schema from "./schema";
935
+
936
+ const url = process.env.DATABASE_URL ?? "${fallback}";
937
+ const client = postgres(url);
938
+
939
+ export const db = drizzle(client, { schema });
940
+ `;
941
+ case "mysql":
942
+ return `import { drizzle } from "drizzle-orm/mysql2";
943
+ import mysql from "mysql2/promise";
944
+ import * as schema from "./schema";
945
+
946
+ const url = process.env.DATABASE_URL ?? "${fallback}";
947
+ const pool = mysql.createPool(url);
948
+
949
+ export const db = drizzle(pool, { schema, mode: "default" });
950
+ `;
951
+ default:
952
+ return `import { mkdirSync } from "node:fs";
953
+ import { dirname } from "node:path";
954
+ import Database from "better-sqlite3";
955
+ import { drizzle } from "drizzle-orm/better-sqlite3";
956
+ import * as schema from "./schema";
957
+
958
+ const url = process.env.DATABASE_URL ?? "${fallback}";
959
+ const fileFromUrl = url.startsWith("file:") ? url.slice("file:".length) : url;
960
+ mkdirSync(dirname(fileFromUrl) || ".", { recursive: true });
961
+ const sqlite = new Database(fileFromUrl);
962
+
963
+ export const db = drizzle(sqlite, { schema });
964
+ `;
965
+ }
966
+ }
967
+ function betterAuthServer(config) {
968
+ const provider = betterAuthProvider(config);
969
+ const dbImport = dbModuleImport(config);
970
+ const schemaImport = dbModuleImport(config, "schema");
971
+ return `import { betterAuth } from "better-auth";
972
+ import { drizzleAdapter } from "better-auth/adapters/drizzle";
973
+ import { nextCookies } from "better-auth/next-js";
974
+ import { organization } from "better-auth/plugins";
975
+ import { and, eq } from "drizzle-orm";
976
+ import { db } from "${dbImport}";
977
+ import * as schema from "${schemaImport}";
978
+ import {
979
+ invitation as invitationTable,
980
+ member,
981
+ organization as organizationTable,
982
+ user as userTable,
983
+ } from "${schemaImport}";
984
+
985
+ function newId(): string {
986
+ return crypto.randomUUID().replaceAll("-", "");
987
+ }
988
+
989
+ export const auth = betterAuth({
990
+ database: drizzleAdapter(db, { provider: "${provider}", schema }),
991
+ emailAndPassword: {
992
+ enabled: true,
993
+ sendResetPassword: async ({ url }) => {
994
+ console.info("Password reset URL:", url);
995
+ },
996
+ },
997
+ databaseHooks: {
998
+ session: {
999
+ create: {
1000
+ before: async (session) => {
1001
+ const [existing] = await db
1002
+ .select({ organizationId: member.organizationId })
1003
+ .from(member)
1004
+ .where(eq(member.userId, session.userId))
1005
+ .limit(1);
1006
+ if (existing?.organizationId) {
1007
+ return { data: { ...session, activeOrganizationId: existing.organizationId } };
1008
+ }
1009
+ const [owner] = await db
1010
+ .select({ name: userTable.name, email: userTable.email })
1011
+ .from(userTable)
1012
+ .where(eq(userTable.id, session.userId))
1013
+ .limit(1);
1014
+ if (owner?.email) {
1015
+ const [pending] = await db
1016
+ .select({ id: invitationTable.id })
1017
+ .from(invitationTable)
1018
+ .where(
1019
+ and(eq(invitationTable.email, owner.email), eq(invitationTable.status, "pending")),
1020
+ )
1021
+ .limit(1);
1022
+ if (pending) return;
1023
+ }
1024
+ const orgId = newId();
1025
+ const now = new Date();
1026
+ await db.insert(organizationTable).values({
1027
+ id: orgId,
1028
+ name: owner?.name.trim() || "Workspace",
1029
+ slug: \`ws-\${session.userId.slice(0, 16)}\`,
1030
+ createdAt: now,
1031
+ });
1032
+ await db.insert(member).values({
1033
+ id: newId(),
1034
+ organizationId: orgId,
1035
+ userId: session.userId,
1036
+ role: "owner",
1037
+ createdAt: now,
1038
+ });
1039
+ return { data: { ...session, activeOrganizationId: orgId } };
1040
+ },
1041
+ },
1042
+ },
1043
+ },
1044
+ plugins: [
1045
+ organization({
1046
+ sendInvitationEmail: async (data) => {
1047
+ const base = process.env.BETTER_AUTH_URL ?? "http://localhost:3000";
1048
+ console.info(\`Invite \${data.email}: \${base}/accept-invitation?id=\${data.id}\`);
1049
+ },
1050
+ }),
1051
+ nextCookies(),
1052
+ ],
1053
+ secret: process.env.BETTER_AUTH_SECRET,
1054
+ baseURL: process.env.BETTER_AUTH_URL,
1055
+ });
1056
+ `;
1057
+ }
1058
+ function betterAuthMiddleware() {
1059
+ return `import { NextRequest, NextResponse } from "next/server";
1060
+ import { getSessionCookie } from "better-auth/cookies";
1061
+
1062
+ const AUTH_PAGES = ["/login", "/signup", "/forgot-password"];
1063
+
1064
+ function invitationOf(request: NextRequest): string | null {
1065
+ const { pathname, searchParams } = request.nextUrl;
1066
+ return (
1067
+ searchParams.get("invitation") ??
1068
+ (pathname === "/accept-invitation" ? searchParams.get("id") : null)
1069
+ );
1070
+ }
1071
+
1072
+ export function middleware(request: NextRequest) {
1073
+ const sessionCookie = getSessionCookie(request);
1074
+ const path = request.nextUrl.pathname;
1075
+ const isAuthPage = AUTH_PAGES.includes(path);
1076
+ const invitation = invitationOf(request);
1077
+ if (!sessionCookie && path === "/accept-invitation") {
1078
+ const url = new URL("/signup", request.url);
1079
+ if (invitation) url.searchParams.set("invitation", invitation);
1080
+ return NextResponse.redirect(url);
1081
+ }
1082
+ if (!sessionCookie && !isAuthPage) {
1083
+ const url = new URL("/login", request.url);
1084
+ if (invitation) url.searchParams.set("invitation", invitation);
1085
+ return NextResponse.redirect(url);
1086
+ }
1087
+ if (sessionCookie && isAuthPage) {
1088
+ if (invitation) {
1089
+ return NextResponse.redirect(
1090
+ new URL(\`/accept-invitation?id=\${encodeURIComponent(invitation)}\`, request.url),
1091
+ );
1092
+ }
1093
+ return NextResponse.redirect(new URL("/", request.url));
1094
+ }
1095
+ return NextResponse.next();
1096
+ }
1097
+
1098
+ export const config = {
1099
+ matcher: ["/((?!api/auth|_next/static|_next/image|favicon.ico|.*\\\\..*).*)"],
1100
+ };
1101
+ `;
1102
+ }
1103
+ function acceptInvitationPage(config) {
1104
+ const authClientImport = fromAppImport(config, "lib/auth-client");
1105
+ return `"use client";
1106
+
1107
+ import { Suspense, useEffect, useState } from "react";
1108
+ import { useRouter, useSearchParams } from "next/navigation";
1109
+ import { authClient } from "${authClientImport}";
1110
+
1111
+ function AcceptInvitation() {
1112
+ const router = useRouter();
1113
+ const params = useSearchParams();
1114
+ const id = params.get("id") ?? params.get("invitation");
1115
+ const { data: session, isPending } = authClient.useSession();
1116
+ const [error, setError] = useState<string | null>(null);
1117
+
1118
+ useEffect(() => {
1119
+ if (isPending) return;
1120
+ if (!id) {
1121
+ setError("Invitation is missing.");
1122
+ return;
1123
+ }
1124
+ if (!session) {
1125
+ router.replace(\`/signup?invitation=\${encodeURIComponent(id)}\`);
1126
+ return;
1127
+ }
1128
+ let cancelled = false;
1129
+ void (async () => {
1130
+ const { data, error: acceptError } = await authClient.organization.acceptInvitation({
1131
+ invitationId: id,
1132
+ });
1133
+ if (cancelled) return;
1134
+ if (acceptError) {
1135
+ setError(acceptError.message || "Could not accept invitation.");
1136
+ return;
1137
+ }
1138
+ const orgId = data?.invitation?.organizationId ?? data?.member?.organizationId;
1139
+ if (orgId) {
1140
+ await authClient.organization.setActive({ organizationId: orgId });
1141
+ }
1142
+ try {
1143
+ sessionStorage.removeItem("cronus-invitation");
1144
+ } catch {
1145
+ // ignore
1146
+ }
1147
+ window.location.assign("/");
1148
+ })();
1149
+ return () => {
1150
+ cancelled = true;
1151
+ };
1152
+ }, [id, isPending, router, session]);
1153
+
1154
+ return (
1155
+ <main>
1156
+ {error ? <p role="alert">{error}</p> : <p>Accepting invitation…</p>}
1157
+ </main>
1158
+ );
1159
+ }
1160
+
1161
+ export default function AcceptInvitationPage() {
1162
+ return (
1163
+ <Suspense fallback={<main><p>Accepting invitation…</p></main>}>
1164
+ <AcceptInvitation />
1165
+ </Suspense>
1166
+ );
1167
+ }
1168
+ `;
1169
+ }
1170
+ function betterAuthClient() {
1171
+ return `import { organizationClient } from "better-auth/client/plugins";
1172
+ import { createAuthClient } from "better-auth/react";
1173
+
1174
+ export const authClient = createAuthClient({
1175
+ plugins: [organizationClient()],
1176
+ });
1177
+ `;
1178
+ }
1179
+ function betterAuthRoute(config) {
1180
+ return `import { auth } from "${authModuleImport(config)}";
1181
+ import { toNextJsHandler } from "better-auth/next-js";
1182
+
1183
+ export const { GET, POST } = toNextJsHandler(auth);
1184
+ `;
1185
+ }
357
1186
  function unsupportedNotes(config) {
358
1187
  const notes = [];
359
1188
  if (single(config, "web") !== "web-next") {
@@ -367,10 +1196,15 @@ function unsupportedNotes(config) {
367
1196
  single(config, "backend") !== "backend-fullstack-next") {
368
1197
  notes.push("Add the selected dedicated backend service described in KICKOFF.md.");
369
1198
  }
370
- if (single(config, "database") !== "db-none") {
1199
+ if (emitsDrizzle(config)) {
1200
+ if (HOSTED_DB_SETUPS.has(single(config, "dbSetup") ?? "")) {
1201
+ notes.push("Configure the selected hosted database provider; generated Drizzle files use a local DATABASE_URL.");
1202
+ }
1203
+ }
1204
+ else if (single(config, "database") !== "db-none") {
371
1205
  notes.push("Wire the selected database/ORM/provider before running db:push.");
372
1206
  }
373
- if (single(config, "auth") !== "auth-none") {
1207
+ if (!emitsBetterAuth(config) && single(config, "auth") !== "auth-none") {
374
1208
  notes.push("Implement the selected auth provider and protect mutating routes.");
375
1209
  }
376
1210
  if (single(config, "payments") !== "pay-none") {
@@ -388,9 +1222,6 @@ function unsupportedNotes(config) {
388
1222
  if (mcpIds.includes("mcp-cronus-ui") && !usesCronusUi(config)) {
389
1223
  notes.push("The cronus-ui MCP server is not generated for stacks that do not use Cronus UI.");
390
1224
  }
391
- if (multi(config, "skills").length > 0) {
392
- notes.push("Install the selected agent skill packs in your agent environment; KICKOFF.md records the choices but the scaffold does not install external skills.");
393
- }
394
1225
  const unsupportedAddons = multi(config, "addons").filter((id) => id !== "addon-biome");
395
1226
  if (unsupportedAddons.length > 0) {
396
1227
  notes.push("Wire the selected addons manually unless noted otherwise; this generator currently scaffolds Biome config and records the rest in KICKOFF.md.");
@@ -418,7 +1249,7 @@ export function scaffoldStack(options) {
418
1249
  emit("README.md", readme(projectName, config, unsupported));
419
1250
  emit("KICKOFF.md", generateKickoff(config, projectName, catalog));
420
1251
  emit("stack.json", `${generateStackJson(config, projectName)}\n`);
421
- emit(".gitignore", "node_modules\n.next\ndist\n.env*\n!.env.example\n.DS_Store\n");
1252
+ emit(".gitignore", gitignore(config));
422
1253
  emit("tsconfig.json", tsconfig(config));
423
1254
  emit(".env.example", envExample(config));
424
1255
  if (single(config, "commitStyle") === "commit-conventional") {
@@ -429,13 +1260,13 @@ export function scaffoldStack(options) {
429
1260
  }
430
1261
  if (single(config, "web") === "web-next") {
431
1262
  const isCronusUi = usesCronusUi(config);
432
- emit("next.config.mjs", "/** @type {import('next').NextConfig} */\nconst nextConfig = {};\n\nexport default nextConfig;\n");
1263
+ emit("next.config.mjs", nextConfigMjs(config));
433
1264
  const app = appDir(config);
434
1265
  if (isCronusUi) {
435
1266
  emit("postcss.config.mjs", 'export default { plugins: { "@tailwindcss/postcss": {} } };\n');
436
1267
  emit(`${app}/globals.css`, globalsCss(config));
437
1268
  emit(`${app}/layout.tsx`, layoutTsx(projectName));
438
- emit(`${app}/page.tsx`, pageTsx());
1269
+ emit(`${app}/page.tsx`, pageTsx(config));
439
1270
  emit("cronus-ui.json", `${JSON.stringify({
440
1271
  aliases: { ui: "@/components/ui", lib: "@/lib", blocks: "@/components/blocks" },
441
1272
  paths: cronusUiPaths(config),
@@ -448,6 +1279,18 @@ export function scaffoldStack(options) {
448
1279
  emit(`${app}/layout.tsx`, neutralLayoutTsx(projectName));
449
1280
  emit(`${app}/page.tsx`, neutralPageTsx(config));
450
1281
  }
1282
+ if (emitsDrizzle(config)) {
1283
+ emit("drizzle.config.ts", drizzleConfig(config));
1284
+ emit(`${dbDir(config)}/schema.ts`, drizzleSchema(config));
1285
+ emit(`${dbDir(config)}/index.ts`, drizzleClient(config));
1286
+ }
1287
+ if (emitsBetterAuth(config)) {
1288
+ emit(`${libDir(config)}/auth.ts`, betterAuthServer(config));
1289
+ emit(`${libDir(config)}/auth-client.ts`, betterAuthClient());
1290
+ emit(`${app}/api/auth/[...all]/route.ts`, betterAuthRoute(config));
1291
+ emit(single(config, "structure") === "structure-root" ? "middleware.ts" : "src/middleware.ts", betterAuthMiddleware());
1292
+ emit(`${app}/accept-invitation/page.tsx`, acceptInvitationPage(config));
1293
+ }
451
1294
  }
452
1295
  else {
453
1296
  emit("src/index.ts", basicIndex(projectName));
@@ -461,6 +1304,7 @@ export function scaffoldStack(options) {
461
1304
  name: projectName,
462
1305
  assistants,
463
1306
  preset: "standard",
1307
+ skills: kitSkillsFromConfig(config),
464
1308
  includeCronusUi: isCronusUi,
465
1309
  cronusUiMcp,
466
1310
  });
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const CREATE_STACK_VERSION = "0.6.0";
1
+ export declare const CREATE_STACK_VERSION = "0.6.2";
2
2
  //# sourceMappingURL=version.d.ts.map
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
- export const CREATE_STACK_VERSION = "0.6.0";
1
+ export const CREATE_STACK_VERSION = "0.6.2";
2
2
  //# sourceMappingURL=version.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-cronus-stack",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
4
4
  "description": "Scaffold a Cronus stack from the Cronus Stack Builder contract: app files, stack.json, KICKOFF.md, and optional AI Kit.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -44,8 +44,8 @@
44
44
  "prepublishOnly": "tsc -p tsconfig.json"
45
45
  },
46
46
  "dependencies": {
47
- "@cronus-ui/ai-kit": "0.6.0",
48
- "@cronus-ui/stack": "0.6.0"
47
+ "@cronus-ui/ai-kit": "0.6.2",
48
+ "@cronus-ui/stack": "0.6.2"
49
49
  },
50
50
  "devDependencies": {
51
51
  "@types/node": "^22.10.0",