create-stackrjs 1.5.0 → 2.0.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/README.md CHANGED
@@ -40,24 +40,27 @@ npm create stackrjs@latest [name] -- [options]
40
40
  # or: npx create-stackrjs [name] [options]
41
41
 
42
42
  --architecture <a> monolith | microservices
43
- --database <db> postgres | mysql | mongodb
44
- --orm <orm> prisma | drizzle | mongoose
43
+ --database <db> postgres | mysql | mongodb | none
44
+ --orm <orm> prisma | drizzle | mongoose | none
45
45
  --auth <auth> nextauth | better-auth | clerk | supabase
46
46
  --styling <s> tailwind | shadcn (shadcn ships the full Tailwind stack)
47
- --interfaces <list> auth-pages,dashboard,admin,landing
47
+ --interfaces <list> auth-pages,dashboard,admin,landing (`--interfaces=` for none)
48
48
  -y, --yes skip the wizard, use flags + defaults
49
49
  --ci non-interactive: implies --yes --no-install --no-git
50
50
  --no-install skip npm install
51
51
  --no-git skip git init
52
52
  ```
53
53
 
54
- Run with no options for an interactive wizard (`@clack/prompts`).
54
+ Run with no options for an interactive wizard (`@clack/prompts`). The flag list
55
+ above is printed by `--help`, generated from the registry so it can't drift. An
56
+ unknown flag is an error, not a silently-ignored token.
55
57
 
56
58
  ### Compatible by construction
57
59
 
58
60
  The dimensions are linked, so you can't assemble an incompatible stack. Picking a
59
61
  technology constrains the rest (MongoDB rules out Drizzle; Supabase Auth needs
60
- Postgres; shadcn implies Tailwind). Incompatible flag combinations are rejected
62
+ Postgres; shadcn implies Tailwind; the microservices bundle ships its own data
63
+ layer, auth and pages, so it pins every other dimension but styling). Incompatible flag combinations are rejected
61
64
  with a clear message, and both the wizard and the [web configurator](#web-configurator)
62
65
  grey out options that clash with your current choices or aren't generatable yet.
63
66
  The catalogue + rules live in one place — `src/shared/registry.ts` — consumed by the
@@ -89,9 +92,16 @@ npm run dev # http://localhost:3000
89
92
  ```
90
93
 
91
94
  The configurator reads the same option catalogue + compatibility rules as the CLI.
92
- `web/src/lib/stack.ts` is a mirror of `src/shared/registry.ts` (the deployable Next
93
- app imports no TypeScript from the engine); the `test/registry-sync.test.ts` suite
94
- fails if the two ever drift, so the registry stays the single source of truth.
95
+ Since the deployable Next app compiles nothing from outside `web/`, the shared
96
+ modules are generated into it:
97
+
98
+ ```bash
99
+ npm run gen:web # src/shared/{registry,compat}.ts -> web/src/lib/shared/
100
+ ```
101
+
102
+ `web/src/lib/stack.ts` holds only the UI on top (grouping, `reconcile`, the command
103
+ builder). CI re-runs the generator and fails on any diff, so `src/shared/registry.ts`
104
+ stays the single source of truth.
95
105
 
96
106
  ## Develop Stackr
97
107
 
@@ -99,6 +109,7 @@ fails if the two ever drift, so the registry stays the single source of truth.
99
109
  npm install
100
110
  npm run build # bundle the CLI with tsup -> dist/
101
111
  npm run typecheck # type-check the engine
112
+ npm run gen:web # copy src/shared into the web configurator
102
113
  npm run smoke # generate every stack and verify each installs + builds
103
114
  node dist/index.js demo --ci # try it locally
104
115
  ```
@@ -112,6 +123,7 @@ npm test # engine: flags, selections, package.json/env merge, install
112
123
  # plus an integration test that scaffolds a project to a temp dir
113
124
  npm run smoke # heavier: generate each stack (monolith, shadcn, microservices),
114
125
  # then npm install, tsc and build the output
126
+ npm run smoke -- clerk # a single case; `-- --list` prints the case names
115
127
  cd web && npm test # the command builder
116
128
  ```
117
129
 
@@ -121,6 +133,9 @@ Two GitHub Actions workflows:
121
133
 
122
134
  - **`.github/workflows/ci.yml`** — on every push/PR: typecheck, test and build the CLI
123
135
  (Node 18/20/22) and the web app.
136
+ - **`.github/workflows/smoke.yml`** — nightly, on demand, and on any PR touching
137
+ `template/**` or the installers: generates every stack in a parallel job matrix and
138
+ checks each one installs, type-checks and builds.
124
139
  - **`.github/workflows/release.yml`** — on push to `main`: runs the checks, then
125
140
  [`semantic-release`](https://semantic-release.gitbook.io) versions, publishes to npm
126
141
  (with provenance), tags, writes `CHANGELOG.md` and creates a GitHub Release —
package/dist/index.js CHANGED
@@ -3,12 +3,28 @@
3
3
  // src/index.ts
4
4
  import { createRequire } from "module";
5
5
 
6
+ // src/cli/flags.ts
7
+ import { parseArgs as nodeParseArgs } from "util";
8
+
6
9
  // src/shared/registry.ts
7
10
  var REGISTRY = {
8
11
  architecture: [
9
12
  { value: "monolith", label: "Monolith", status: "stable" },
10
- // The microservices bundle ships its own Postgres data layer.
11
- { value: "microservices", label: "Microservices", status: "stable", requires: { database: ["postgres"] } }
13
+ // The microservices bundle is a fixed stack: it ships its own Prisma/Postgres
14
+ // data layer, its own session auth in the API service, and its own web pages.
15
+ // The other dimensions can't be layered onto it, so it pins them (and an empty
16
+ // `interfaces` list means "no interface option fits").
17
+ {
18
+ value: "microservices",
19
+ label: "Microservices",
20
+ status: "stable",
21
+ requires: {
22
+ database: ["postgres"],
23
+ orm: ["prisma"],
24
+ auth: ["nextauth"],
25
+ interfaces: []
26
+ }
27
+ }
12
28
  ],
13
29
  database: [
14
30
  { value: "postgres", label: "PostgreSQL", status: "stable" },
@@ -79,9 +95,6 @@ var OPTION_LABELS = Object.fromEntries(
79
95
  DIMENSIONS.flatMap((d) => REGISTRY[d].map((o) => [o.value, o.label]))
80
96
  );
81
97
  var optionLabel = (value) => OPTION_LABELS[value] ?? value;
82
- var ALL_VALUES = Object.fromEntries(
83
- DIMENSIONS.map((d) => [d, REGISTRY[d].map((o) => o.value)])
84
- );
85
98
  var STABLE_VALUES = Object.fromEntries(
86
99
  DIMENSIONS.map((d) => [d, REGISTRY[d].filter((o) => o.status === "stable").map((o) => o.value)])
87
100
  );
@@ -93,75 +106,48 @@ function findOption(dimension, value) {
93
106
  }
94
107
 
95
108
  // src/cli/flags.ts
96
- var SUPPORTED = STABLE_VALUES;
97
- var VALUE_FLAGS = /* @__PURE__ */ new Set([
98
- "architecture",
99
- "database",
100
- "orm",
101
- "auth",
102
- "styling",
103
- "interfaces"
104
- ]);
105
- function parseArgs(argv) {
106
- const result = { positionals: [], yes: false, help: false, version: false };
107
- const positionals = [];
108
- for (let i = 0; i < argv.length; i++) {
109
- const token = argv[i];
110
- if (!token.startsWith("-")) {
111
- positionals.push(token);
112
- continue;
113
- }
114
- let key = token.replace(/^--?/, "");
115
- let value;
116
- const eq = key.indexOf("=");
117
- if (eq !== -1) {
118
- value = key.slice(eq + 1);
119
- key = key.slice(0, eq);
120
- }
121
- switch (key) {
122
- case "help":
123
- case "h":
124
- result.help = true;
125
- break;
126
- case "version":
127
- case "v":
128
- result.version = true;
129
- break;
130
- case "yes":
131
- case "y":
132
- result.yes = true;
133
- break;
134
- case "ci":
135
- result.yes = true;
136
- result.install = false;
137
- result.git = false;
138
- break;
139
- case "install":
140
- result.install = true;
141
- break;
142
- case "no-install":
143
- result.install = false;
144
- break;
145
- case "git":
146
- result.git = true;
147
- break;
148
- case "no-git":
149
- result.git = false;
150
- break;
151
- default: {
152
- if (VALUE_FLAGS.has(key)) {
153
- if (value === void 0) value = argv[++i];
154
- if (key === "interfaces") {
155
- result.interfaces = (value ?? "").split(",").map((s) => s.trim()).filter(Boolean);
156
- } else {
157
- result[key] = value;
158
- }
159
- }
160
- }
161
- }
109
+ var UsageError = class extends Error {
110
+ };
111
+ var OPTIONS = {
112
+ ...Object.fromEntries(
113
+ DIMENSIONS.map((d) => [d, { type: "string" }])
114
+ ),
115
+ yes: { type: "boolean", short: "y" },
116
+ ci: { type: "boolean" },
117
+ install: { type: "boolean" },
118
+ "no-install": { type: "boolean" },
119
+ git: { type: "boolean" },
120
+ "no-git": { type: "boolean" },
121
+ help: { type: "boolean", short: "h" },
122
+ version: { type: "boolean", short: "v" }
123
+ };
124
+ function strictParse(argv) {
125
+ try {
126
+ return nodeParseArgs({ args: argv, options: OPTIONS, allowPositionals: true, strict: true });
127
+ } catch (err) {
128
+ throw new UsageError(err instanceof Error ? err.message : String(err));
162
129
  }
163
- result.positionals = positionals;
164
- return result;
130
+ }
131
+ function parseArgs(argv) {
132
+ const { values, positionals } = strictParse(argv);
133
+ const ci = values.ci === true;
134
+ const flag = (on, off) => on === true ? true : off === true || ci ? false : void 0;
135
+ const install = flag(values.install, values["no-install"]);
136
+ const git = flag(values.git, values["no-git"]);
137
+ return {
138
+ positionals,
139
+ architecture: values.architecture,
140
+ database: values.database,
141
+ orm: values.orm,
142
+ auth: values.auth,
143
+ styling: values.styling,
144
+ interfaces: values.interfaces === void 0 ? void 0 : values.interfaces.split(",").map((i) => i.trim()).filter(Boolean),
145
+ yes: values.yes === true || ci,
146
+ install,
147
+ git,
148
+ help: values.help === true,
149
+ version: values.version === true
150
+ };
165
151
  }
166
152
 
167
153
  // src/cli/create.ts
@@ -180,13 +166,32 @@ function getTemplateDir() {
180
166
  // src/utils/logger.ts
181
167
  import pc from "picocolors";
182
168
  var logger = {
183
- info: (msg) => console.log(msg),
184
169
  success: (msg) => console.log(pc.green(msg)),
185
- warn: (msg) => console.log(pc.yellow(msg)),
186
170
  error: (msg) => console.error(pc.red(msg)),
187
171
  dim: (msg) => console.log(pc.dim(msg))
188
172
  };
189
173
 
174
+ // src/utils/packageManager.ts
175
+ function detectPackageManager() {
176
+ const agent = process.env.npm_config_user_agent ?? "";
177
+ if (agent.startsWith("pnpm")) return "pnpm";
178
+ if (agent.startsWith("yarn")) return "yarn";
179
+ if (agent.startsWith("bun")) return "bun";
180
+ return "npm";
181
+ }
182
+ function execCommand(pm) {
183
+ switch (pm) {
184
+ case "pnpm":
185
+ return "pnpm exec";
186
+ case "yarn":
187
+ return "yarn";
188
+ case "bun":
189
+ return "bunx";
190
+ case "npm":
191
+ return "npx";
192
+ }
193
+ }
194
+
190
195
  // src/helpers/scaffoldProject.ts
191
196
  import { promises as fs6 } from "fs";
192
197
  import path20 from "path";
@@ -488,57 +493,46 @@ var supabaseInstaller = async (ctx) => {
488
493
  });
489
494
  };
490
495
 
491
- // src/installers/tailwind.ts
496
+ // src/installers/styling.ts
492
497
  import path13 from "path";
493
498
  var TAILWIND_DEV_DEPS = {
494
499
  tailwindcss: "^3.4.17",
495
500
  postcss: "^8.4.49",
496
501
  autoprefixer: "^10.4.20"
497
502
  };
498
- var tailwindInstaller = async (ctx) => {
499
- const webDir = path13.join(ctx.projectDir, ctx.layout.webRoot);
500
- await copyTemplate(path13.join(ctx.templateDir, "extras", "tailwind"), webDir);
501
- if (ctx.layout.multiService) {
502
- await mergePackageDependencies(path13.join(webDir, "package.json"), {}, TAILWIND_DEV_DEPS);
503
- } else {
504
- addDevDependencies(ctx.pkg, TAILWIND_DEV_DEPS);
505
- }
506
- };
507
-
508
- // src/installers/shadcn.ts
509
- import path14 from "path";
510
503
  var SHADCN_DEPS = {
511
504
  "class-variance-authority": "^0.7.1",
512
505
  clsx: "^2.1.1",
513
506
  "tailwind-merge": "^2.6.0",
514
507
  "lucide-react": "^0.469.0"
515
508
  };
516
- var SHADCN_DEV_DEPS = {
517
- tailwindcss: "^3.4.17",
518
- postcss: "^8.4.49",
519
- autoprefixer: "^10.4.20",
520
- "tailwindcss-animate": "^1.0.7"
521
- };
522
- var shadcnInstaller = async (ctx) => {
523
- const webDir = path14.join(ctx.projectDir, ctx.layout.webRoot);
524
- await copyTemplate(path14.join(ctx.templateDir, "extras", "shadcn"), webDir);
509
+ var SHADCN_DEV_DEPS = { ...TAILWIND_DEV_DEPS, "tailwindcss-animate": "^1.0.7" };
510
+ async function installStyling(ctx, templateName, deps, devDeps) {
511
+ const webDir = path13.join(ctx.projectDir, ctx.layout.webRoot);
512
+ await copyTemplate(path13.join(ctx.templateDir, "extras", templateName), webDir);
525
513
  if (ctx.layout.multiService) {
526
- await mergePackageDependencies(path14.join(webDir, "package.json"), SHADCN_DEPS, SHADCN_DEV_DEPS);
527
- } else {
528
- addDependencies(ctx.pkg, SHADCN_DEPS);
529
- addDevDependencies(ctx.pkg, SHADCN_DEV_DEPS);
514
+ await mergePackageDependencies(path13.join(webDir, "package.json"), deps, devDeps);
515
+ return;
530
516
  }
531
- };
517
+ addDependencies(ctx.pkg, deps);
518
+ addDevDependencies(ctx.pkg, devDeps);
519
+ }
520
+ var tailwindInstaller = (ctx) => installStyling(ctx, "tailwind", {}, TAILWIND_DEV_DEPS);
521
+ var shadcnInstaller = (ctx) => installStyling(ctx, "shadcn", SHADCN_DEPS, SHADCN_DEV_DEPS);
532
522
 
533
523
  // src/installers/auth-pages.ts
534
- import path15 from "path";
524
+ import path14 from "path";
535
525
  var authPagesInstaller = async (ctx) => {
536
526
  if (ctx.selections.auth !== "nextauth") return;
537
- await copyTemplate(path15.join(ctx.templateDir, "extras", "auth-pages"), ctx.projectDir);
527
+ await copyTemplate(path14.join(ctx.templateDir, "extras", "auth-pages"), ctx.projectDir);
538
528
  const overlay = `auth-pages-${ctx.selections.orm}`;
539
- await copyTemplate(path15.join(ctx.templateDir, "extras", overlay), ctx.projectDir);
529
+ await copyTemplate(path14.join(ctx.templateDir, "extras", overlay), ctx.projectDir);
540
530
  };
541
531
 
532
+ // src/installers/auth-ui.ts
533
+ import path15 from "path";
534
+ var authUiInstaller = (ctx) => copyTemplate(path15.join(ctx.templateDir, "extras", "auth-ui"), ctx.projectDir);
535
+
542
536
  // src/installers/account.ts
543
537
  import path16 from "path";
544
538
  var accountAdapterInstaller = async (ctx) => {
@@ -615,6 +609,8 @@ function resolveInstallers(selections) {
615
609
  if (selections.auth === "better-auth") installers.push(betterAuthInstaller);
616
610
  if (selections.auth === "clerk") installers.push(clerkInstaller);
617
611
  if (selections.auth === "supabase") installers.push(supabaseInstaller);
612
+ const rendersAuthPages = selections.auth === "better-auth" || selections.auth === "supabase" || selections.auth === "nextauth" && selections.interfaces.includes("auth-pages");
613
+ if (rendersAuthPages) installers.push(authUiInstaller);
618
614
  if (selections.styling === "tailwind") installers.push(tailwindInstaller);
619
615
  if (selections.styling === "shadcn") installers.push(shadcnInstaller);
620
616
  if (selections.interfaces.includes("auth-pages")) installers.push(authPagesInstaller);
@@ -636,16 +632,122 @@ function getLayout(architecture) {
636
632
  return LAYOUTS[architecture];
637
633
  }
638
634
 
635
+ // src/shared/compat.ts
636
+ function selectedValues(selection, dimension) {
637
+ const raw = selection[dimension];
638
+ if (raw === void 0) return [];
639
+ return Array.isArray(raw) ? raw : [raw];
640
+ }
641
+ function conflict(selection, dimension, value) {
642
+ const own = findOption(dimension, value);
643
+ for (const other of DIMENSIONS) {
644
+ if (other === dimension) continue;
645
+ for (const otherValue of selectedValues(selection, other)) {
646
+ const forward = own?.requires?.[other];
647
+ if (forward && !forward.includes(otherValue)) return [other, otherValue];
648
+ const backward = findOption(other, otherValue)?.requires?.[dimension];
649
+ if (backward && !backward.includes(value)) return [other, otherValue];
650
+ }
651
+ }
652
+ return void 0;
653
+ }
654
+ function resolveAvailability(selection) {
655
+ const out = {};
656
+ for (const dimension of DIMENSIONS) {
657
+ out[dimension] = REGISTRY[dimension].map((option) => {
658
+ const clash = conflict(selection, dimension, option.value);
659
+ const generatable = option.status === "stable";
660
+ let reason;
661
+ if (clash) {
662
+ reason = `incompatible with ${clash[0]} "${optionLabel(clash[1])}"`;
663
+ } else if (!generatable) {
664
+ reason = "coming soon";
665
+ }
666
+ return { value: option.value, enabled: !clash, generatable, reason };
667
+ });
668
+ }
669
+ return out;
670
+ }
671
+ function validateCompat(selection) {
672
+ const errors = [];
673
+ const seen = /* @__PURE__ */ new Set();
674
+ for (const dimension of DIMENSIONS) {
675
+ for (const value of selectedValues(selection, dimension)) {
676
+ const clash = conflict(selection, dimension, value);
677
+ if (!clash) continue;
678
+ const key = [dimension, value, clash[0], clash[1]].sort().join("|");
679
+ if (seen.has(key)) continue;
680
+ seen.add(key);
681
+ errors.push(
682
+ `${dimension} "${optionLabel(value)}" is incompatible with ${clash[0]} "${optionLabel(clash[1])}".`
683
+ );
684
+ }
685
+ }
686
+ return errors;
687
+ }
688
+ function impliedInterfaces(auth, interfaces) {
689
+ const wantsAccount = interfaces.includes("dashboard") || interfaces.includes("admin");
690
+ if (auth !== "nextauth" || !wantsAccount || interfaces.includes("auth-pages")) return interfaces;
691
+ return ["auth-pages", ...interfaces];
692
+ }
693
+ function defaultSelection(pinned) {
694
+ const singles = DIMENSIONS.filter((d) => d !== "interfaces");
695
+ const out = { ...pinned };
696
+ for (let pass = 0; pass < singles.length; pass++) {
697
+ const availability = resolveAvailability(out);
698
+ let changed = false;
699
+ for (const dimension of singles) {
700
+ if (pinned[dimension] !== void 0) continue;
701
+ const pick = availability[dimension].find((a) => a.enabled && a.generatable)?.value;
702
+ if (pick !== void 0 && pick !== out[dimension]) {
703
+ out[dimension] = pick;
704
+ changed = true;
705
+ }
706
+ }
707
+ if (!changed) break;
708
+ }
709
+ if (pinned.interfaces === void 0) {
710
+ const availability = resolveAvailability(out);
711
+ out.interfaces = [DEFAULT_VALUES.interfaces].filter(
712
+ (value) => availability.interfaces.some((a) => a.value === value && a.enabled && a.generatable)
713
+ );
714
+ }
715
+ return out;
716
+ }
717
+
639
718
  // src/helpers/generateReadme.ts
640
- function generateReadme(selections) {
641
- const interfaces = selections.interfaces.length ? selections.interfaces.map((i) => `- ${optionLabel(i)}`).join("\n") : "- _(none)_";
642
- const dbEngine = { postgres: "Postgres", mysql: "MySQL", mongodb: "MongoDB", none: null }[selections.database];
643
- const gettingStarted = selections.architecture === "microservices" ? microservicesGettingStarted() : monolithGettingStarted(dbEngine, selections.orm);
719
+ function generateReadme(selections, pm) {
720
+ const body = selections.architecture === "microservices" ? microservicesBody(selections, pm) : monolithBody(selections, pm);
644
721
  return `# ${selections.projectName}
645
722
 
646
- Generated with [Stackr](https://github.com/) \u2014 a runnable full-stack Next.js starter.
723
+ Generated with [Stackr](https://github.com/Tristan-stack/stackr) \u2014 a runnable full-stack Next.js starter.
724
+
725
+ ${body}`;
726
+ }
727
+ function monolithBody(selections, pm) {
728
+ const interfaces = selections.interfaces.length ? selections.interfaces.map((i) => `- ${optionLabel(i)}`).join("\n") : "- _(none)_";
729
+ const dbEngine = selections.database === "none" ? null : optionLabel(selections.database);
730
+ const schemaStep = selections.orm === "prisma" ? `
731
+ # 3. Create the database schema
732
+ ${execCommand(pm)} prisma migrate dev --name init
733
+ ` : selections.orm === "drizzle" ? `
734
+ # 3. Create the database schema
735
+ ${pm} run db:push
736
+ ` : "";
737
+ const dbScriptRows = selections.orm === "prisma" ? `| \`${pm} run db:migrate\` | Run Prisma migrations |
738
+ | \`${pm} run db:studio\` | Open Prisma Studio |
739
+ ` : selections.orm === "drizzle" ? `| \`${pm} run db:push\` | Push the schema to the database |
740
+ | \`${pm} run db:studio\` | Open Drizzle Studio |
741
+ ` : "";
742
+ const dbStep = dbEngine ? `# 1. Start a local ${dbEngine} (requires Docker)
743
+ docker compose up -d
647
744
 
648
- ## Stack
745
+ # 2. Install dependencies
746
+ ${pm} install
747
+ ` : `# 1. Install dependencies
748
+ ${pm} install
749
+ `;
750
+ return `## Stack
649
751
 
650
752
  - **Architecture:** ${optionLabel(selections.architecture)}
651
753
  - **Database:** ${optionLabel(selections.database)}
@@ -657,23 +759,12 @@ Generated with [Stackr](https://github.com/) \u2014 a runnable full-stack Next.j
657
759
 
658
760
  ${interfaces}
659
761
 
660
- ${gettingStarted}`;
661
- }
662
- function monolithGettingStarted(dbEngine, orm) {
663
- const schemaStep = orm === "prisma" ? "\n# 3. Create the database schema\nnpx prisma migrate dev --name init\n" : orm === "drizzle" ? "\n# 3. Create the database schema\nnpm run db:push\n" : "";
664
- const dbScriptRows = orm === "prisma" ? "| `npm run db:migrate` | Run Prisma migrations |\n| `npm run db:studio` | Open Prisma Studio |\n" : orm === "drizzle" ? "| `npm run db:push` | Push the schema to the database |\n| `npm run db:studio` | Open Drizzle Studio |\n" : "";
665
- const dbStep = dbEngine ? `# 1. Start a local ${dbEngine} (requires Docker)
666
- docker compose up -d
667
-
668
- # 2. Install dependencies
669
- npm install
670
- ` : "# 1. Install dependencies\nnpm install\n";
671
- return `## Getting started
762
+ ## Getting started
672
763
 
673
764
  \`\`\`bash
674
765
  ${dbStep}${schemaStep}
675
766
  # Run the app
676
- npm run dev
767
+ ${pm} run dev
677
768
  \`\`\`
678
769
 
679
770
  Then open [http://localhost:3000](http://localhost:3000).
@@ -690,15 +781,19 @@ generated \`AUTH_SECRET\` was already created for you.
690
781
 
691
782
  | Script | Description |
692
783
  | ------ | ----------- |
693
- | \`npm run dev\` | Start the dev server |
694
- | \`npm run build\` | Production build |
695
- ${dbScriptRows}| \`npm run typecheck\` | Type-check without emitting |
784
+ | \`${pm} run dev\` | Start the dev server |
785
+ | \`${pm} run build\` | Production build |
786
+ ${dbScriptRows}| \`${pm} run typecheck\` | Type-check without emitting |
696
787
  `;
697
788
  }
698
- function microservicesGettingStarted() {
699
- return `## Architecture
789
+ function microservicesBody(selections, pm) {
790
+ return `## Stack
700
791
 
701
- This is an npm-workspaces monorepo split into independent services:
792
+ - **Architecture:** ${optionLabel(selections.architecture)} (npm workspaces: web + API + gateway)
793
+ - **Database:** PostgreSQL (docker-compose)
794
+ - **ORM:** Prisma, owned by \`services/api\`
795
+ - **Auth:** email + password, sessions signed as JWTs by the API service
796
+ - **Styling:** ${optionLabel(selections.styling)}
702
797
 
703
798
  | Path | Service | Description |
704
799
  | ---- | ------- | ----------- |
@@ -716,13 +811,13 @@ service (prefix stripped) and all other routes to the web app.
716
811
  docker compose up -d db
717
812
 
718
813
  # 2. Install dependencies for every workspace
719
- npm install
814
+ ${pm} install
720
815
 
721
816
  # 3. Create the database schema (runs in services/api)
722
- npm run db:migrate
817
+ ${pm} run db:migrate
723
818
 
724
819
  # 4. Run the web app and the API together
725
- npm run dev
820
+ ${pm} run dev
726
821
  \`\`\`
727
822
 
728
823
  - web: [http://localhost:3000](http://localhost:3000) \xB7 api: [http://localhost:3001](http://localhost:3001)
@@ -746,11 +841,11 @@ generated \`AUTH_SECRET\` was already created for you.
746
841
 
747
842
  | Script | Description |
748
843
  | ------ | ----------- |
749
- | \`npm run dev\` | Start every workspace (web + api) |
750
- | \`npm run build\` | Build every workspace |
751
- | \`npm run db:migrate\` | Run Prisma migrations in the API service |
752
- | \`npm run db:studio\` | Open Prisma Studio for the API database |
753
- | \`npm run compose:up\` | Start the full stack via docker-compose |
844
+ | \`${pm} run dev\` | Start every workspace (web + api) |
845
+ | \`${pm} run build\` | Build every workspace |
846
+ | \`${pm} run db:migrate\` | Run Prisma migrations in the API service |
847
+ | \`${pm} run db:studio\` | Open Prisma Studio for the API database |
848
+ | \`${pm} run compose:up\` | Start the full stack via docker-compose |
754
849
  `;
755
850
  }
756
851
 
@@ -761,14 +856,11 @@ function architectureBase(templateDir, selections) {
761
856
  }
762
857
  return path20.join(templateDir, "base");
763
858
  }
764
- function withImpliedInterfaces(selections) {
765
- const wantsAccount = selections.interfaces.includes("dashboard") || selections.interfaces.includes("admin");
766
- const needsPortal = selections.auth === "nextauth" && wantsAccount && !selections.interfaces.includes("auth-pages");
767
- if (!needsPortal) return selections;
768
- return { ...selections, interfaces: ["auth-pages", ...selections.interfaces] };
769
- }
770
- async function scaffoldProject(templateDir, projectDir, inputSelections) {
771
- const selections = withImpliedInterfaces(inputSelections);
859
+ async function scaffoldProject(templateDir, projectDir, inputSelections, pm = "npm") {
860
+ const selections = {
861
+ ...inputSelections,
862
+ interfaces: impliedInterfaces(inputSelections.auth, inputSelections.interfaces)
863
+ };
772
864
  await fs6.mkdir(projectDir, { recursive: true });
773
865
  await copyTemplate(architectureBase(templateDir, selections), projectDir);
774
866
  const pkgPath = path20.join(projectDir, "package.json");
@@ -784,25 +876,25 @@ async function scaffoldProject(templateDir, projectDir, inputSelections) {
784
876
  await writeEnvFiles(projectDir, env);
785
877
  await fs6.writeFile(
786
878
  path20.join(projectDir, "README.md"),
787
- generateReadme(selections),
879
+ generateReadme(selections, pm),
788
880
  "utf8"
789
881
  );
790
882
  }
791
883
 
792
884
  // src/helpers/installDependencies.ts
793
885
  import { spawn } from "child_process";
794
- function installDependencies(projectDir) {
886
+ function installDependencies(projectDir, pm) {
795
887
  return new Promise((resolve, reject) => {
796
- const child = spawn("npm", ["install"], {
888
+ const child = spawn(pm, ["install"], {
797
889
  cwd: projectDir,
798
890
  stdio: "inherit",
799
- // npm is a .cmd shim on Windows; shell:true resolves it correctly.
891
+ // Package managers are .cmd shims on Windows; shell:true resolves them.
800
892
  shell: process.platform === "win32"
801
893
  });
802
894
  child.on("error", reject);
803
895
  child.on("close", (code) => {
804
896
  if (code === 0) resolve();
805
- else reject(new Error(`npm install exited with code ${code}`));
897
+ else reject(new Error(`${pm} install exited with code ${code}`));
806
898
  });
807
899
  });
808
900
  }
@@ -833,98 +925,43 @@ async function initGit(projectDir) {
833
925
 
834
926
  // src/helpers/logNextSteps.ts
835
927
  import path21 from "path";
836
- function logNextSteps({ projectDir, selections, depsInstalled }) {
837
- const rel = path21.relative(process.cwd(), projectDir) || ".";
838
- const steps = [`cd ${rel}`];
928
+ function logNextSteps({ projectDir, selections, depsInstalled, pm }) {
929
+ const steps = [];
930
+ const step = (command, comment) => steps.push(comment ? `${command.padEnd(26)}# ${comment}` : command);
931
+ step(`cd ${path21.relative(process.cwd(), projectDir) || "."}`);
839
932
  if (selections.architecture === "microservices") {
840
- steps.push("docker compose up -d db # start local Postgres");
841
- if (!depsInstalled) steps.push("npm install # installs every workspace");
842
- steps.push("npm run db:migrate # migrate the API database");
843
- steps.push("npm run dev # web + api together");
933
+ step("docker compose up -d db", "start local Postgres");
934
+ if (!depsInstalled) step(`${pm} install`, "installs every workspace");
935
+ step(`${pm} run db:migrate`, "migrate the API database");
936
+ step(`${pm} run dev`, "web + api together");
844
937
  } else {
845
- const engineLabel = {
846
- postgres: "Postgres",
847
- mysql: "MySQL",
848
- mongodb: "MongoDB"
849
- };
850
- if (engineLabel[selections.database]) {
851
- steps.push(`docker compose up -d # start local ${engineLabel[selections.database]}`);
852
- }
853
- if (!depsInstalled) {
854
- steps.push("npm install");
938
+ if (selections.database !== "none") {
939
+ step("docker compose up -d", `start local ${optionLabel(selections.database)}`);
855
940
  }
941
+ if (!depsInstalled) step(`${pm} install`);
856
942
  if (selections.orm === "prisma") {
857
- steps.push("npx prisma migrate dev --name init");
943
+ step(`${execCommand(pm)} prisma migrate dev --name init`);
858
944
  } else if (selections.orm === "drizzle") {
859
- steps.push("npm run db:push # create the schema with Drizzle");
945
+ step(`${pm} run db:push`, "create the schema with Drizzle");
860
946
  }
861
- steps.push("npm run dev");
947
+ step(`${pm} run dev`);
862
948
  }
863
949
  console.log("");
864
950
  console.log(pc.bold("Next steps:"));
865
- for (const step of steps) {
866
- console.log(" " + pc.cyan(step));
867
- }
951
+ for (const s of steps) console.log(" " + pc.cyan(s));
868
952
  console.log("");
869
- return steps;
870
953
  }
871
954
 
872
955
  // src/cli/wizard.ts
873
956
  import * as p from "@clack/prompts";
874
957
 
875
- // src/shared/compat.ts
876
- function selectedValues(selection, dimension) {
877
- const raw = selection[dimension];
878
- if (raw === void 0) return [];
879
- return Array.isArray(raw) ? raw : [raw];
880
- }
881
- function conflict(selection, dimension, value) {
882
- const own = findOption(dimension, value);
883
- for (const other of DIMENSIONS) {
884
- if (other === dimension) continue;
885
- for (const otherValue of selectedValues(selection, other)) {
886
- const forward = own?.requires?.[other];
887
- if (forward && !forward.includes(otherValue)) return [other, otherValue];
888
- const backward = findOption(other, otherValue)?.requires?.[dimension];
889
- if (backward && !backward.includes(value)) return [other, otherValue];
890
- }
891
- }
958
+ // src/shared/projectName.ts
959
+ var VALID = /^[a-z0-9][a-z0-9-_.]*$/i;
960
+ function validateProjectName(name) {
961
+ if (!name) return "Please enter a project name.";
962
+ if (!VALID.test(name)) return "Use letters, numbers, '-', '_' or '.'.";
892
963
  return void 0;
893
964
  }
894
- function resolveAvailability(selection) {
895
- const out = {};
896
- for (const dimension of DIMENSIONS) {
897
- out[dimension] = REGISTRY[dimension].map((option) => {
898
- const clash = conflict(selection, dimension, option.value);
899
- const generatable = option.status === "stable";
900
- let reason;
901
- if (clash) {
902
- reason = `incompatible with ${clash[0]} "${optionLabel(clash[1])}"`;
903
- } else if (!generatable) {
904
- reason = "coming soon";
905
- }
906
- return { value: option.value, enabled: !clash, generatable, reason };
907
- });
908
- }
909
- return out;
910
- }
911
- function validateCompat(selection) {
912
- const errors = [];
913
- const seen = /* @__PURE__ */ new Set();
914
- for (const dimension of DIMENSIONS) {
915
- for (const value of selectedValues(selection, dimension)) {
916
- const clash = conflict(selection, dimension, value);
917
- if (!clash) continue;
918
- const key = [dimension, value, clash[0], clash[1]].sort().join("|");
919
- if (seen.has(key)) continue;
920
- seen.add(key);
921
- errors.push(
922
- `${dimension} "${optionLabel(value)}" is incompatible with ${clash[0]} "${optionLabel(clash[1])}".`
923
- );
924
- }
925
- }
926
- return errors;
927
- }
928
965
 
929
966
  // src/cli/wizard.ts
930
967
  function bail() {
@@ -946,7 +983,16 @@ function isSelectable(dimension, value, selection) {
946
983
  const a = resolveAvailability(selection)[dimension].find((x) => x.value === value);
947
984
  return a.enabled && a.generatable;
948
985
  }
986
+ function selectable(dimension, selection) {
987
+ return resolveAvailability(selection)[dimension].filter((a) => a.enabled && a.generatable).map((a) => a.value);
988
+ }
949
989
  async function selectDimension(message, dimension, selection) {
990
+ const options = selectable(dimension, selection);
991
+ if (options.length === 1) {
992
+ const only = options[0];
993
+ p.log.info(`${message.replace(/\?$/, "")}: only ${optionLabel(only)} fits your stack.`);
994
+ return only;
995
+ }
950
996
  for (; ; ) {
951
997
  const value = unwrap(
952
998
  await p.select({
@@ -960,45 +1006,40 @@ async function selectDimension(message, dimension, selection) {
960
1006
  p.log.warn(`"${optionLabel(value)}" is ${reason}. Please pick another.`);
961
1007
  }
962
1008
  }
963
- async function runWizard(defaults) {
1009
+ async function runWizard(defaults2) {
964
1010
  p.intro("Stackr \u2014 scaffold a runnable full-stack Next.js project");
965
1011
  const projectName = unwrap(
966
1012
  await p.text({
967
1013
  message: "Project name?",
968
1014
  placeholder: "my-app",
969
- initialValue: defaults.projectName,
970
- validate: (v) => {
971
- if (!v) return "Please enter a project name.";
972
- if (!/^[a-z0-9][a-z0-9-_.]*$/i.test(v)) return "Use letters, numbers, '-', '_' or '.'.";
973
- return void 0;
974
- }
1015
+ initialValue: defaults2.projectName,
1016
+ validate: validateProjectName
975
1017
  })
976
1018
  );
977
1019
  const selection = {};
978
1020
  selection.architecture = await selectDimension("Architecture?", "architecture", selection);
979
1021
  selection.database = await selectDimension("Database?", "database", selection);
980
- if (selection.database === "none") {
981
- selection.orm = "none";
982
- p.log.info("No database selected \u2014 skipping the ORM step.");
983
- } else {
984
- selection.orm = await selectDimension("ORM / data layer?", "orm", selection);
985
- }
1022
+ selection.orm = await selectDimension("ORM / data layer?", "orm", selection);
986
1023
  selection.auth = await selectDimension("Authentication?", "auth", selection);
987
1024
  selection.styling = await selectDimension("Styling?", "styling", selection);
988
- const picked = unwrap(
989
- await p.multiselect({
990
- message: "Pre-built interfaces? (space to toggle)",
991
- options: buildOptions("interfaces", selection),
992
- initialValues: ["auth-pages"],
993
- required: false
994
- })
995
- );
996
1025
  const interfaces = [];
997
- for (const value of picked) {
998
- if (isSelectable("interfaces", value, selection)) interfaces.push(value);
999
- else {
1000
- const reason = resolveAvailability(selection).interfaces.find((x) => x.value === value).reason;
1001
- p.log.warn(`Skipping "${optionLabel(value)}" \u2014 ${reason}.`);
1026
+ if (selectable("interfaces", selection).length === 0) {
1027
+ p.log.info("This stack ships its own pages \u2014 no extra interfaces to add.");
1028
+ } else {
1029
+ const picked = unwrap(
1030
+ await p.multiselect({
1031
+ message: "Pre-built interfaces? (space to toggle)",
1032
+ options: buildOptions("interfaces", selection),
1033
+ initialValues: ["auth-pages"],
1034
+ required: false
1035
+ })
1036
+ );
1037
+ for (const value of picked) {
1038
+ if (isSelectable("interfaces", value, selection)) interfaces.push(value);
1039
+ else {
1040
+ const reason = resolveAvailability(selection).interfaces.find((x) => x.value === value).reason;
1041
+ p.log.warn(`Skipping "${optionLabel(value)}" \u2014 ${reason}.`);
1042
+ }
1002
1043
  }
1003
1044
  }
1004
1045
  selection.interfaces = interfaces;
@@ -1020,25 +1061,31 @@ function projectNameFromArgs(args) {
1020
1061
  return positionals[0];
1021
1062
  }
1022
1063
  function selectionsFromFlags(args) {
1023
- const database = args.database ?? "postgres";
1024
- const ormDefault = database === "none" ? "none" : "prisma";
1025
- const authDefault = database === "none" ? "clerk" : "nextauth";
1064
+ const pinned = {};
1065
+ for (const dimension of DIMENSIONS) {
1066
+ const value = args[dimension];
1067
+ if (value !== void 0) pinned[dimension] = value;
1068
+ }
1069
+ const settled = defaultSelection(pinned);
1070
+ const single = (dimension) => settled[dimension];
1026
1071
  return {
1027
1072
  projectName: projectNameFromArgs(args) ?? "my-app",
1028
- architecture: args.architecture ?? "monolith",
1029
- database,
1030
- orm: args.orm ?? ormDefault,
1031
- auth: args.auth ?? authDefault,
1032
- styling: args.styling ?? "tailwind",
1033
- interfaces: args.interfaces ?? ["auth-pages"]
1073
+ architecture: single("architecture"),
1074
+ database: single("database"),
1075
+ orm: single("orm"),
1076
+ auth: single("auth"),
1077
+ styling: single("styling"),
1078
+ interfaces: settled.interfaces
1034
1079
  };
1035
1080
  }
1036
1081
  function validateSelections(s) {
1037
1082
  const errors = [];
1083
+ const nameError = validateProjectName(s.projectName);
1084
+ if (nameError) errors.push(`project name "${s.projectName}" is invalid. ${nameError}`);
1038
1085
  const check = (dim, value) => {
1039
- if (!SUPPORTED[dim].includes(value)) {
1086
+ if (!STABLE_VALUES[dim].includes(value)) {
1040
1087
  errors.push(
1041
- `${dim} "${value}" is not supported yet (currently generatable: ${SUPPORTED[dim].join(", ")}).`
1088
+ `${dim} "${value}" is not supported yet (currently generatable: ${STABLE_VALUES[dim].join(", ")}).`
1042
1089
  );
1043
1090
  }
1044
1091
  };
@@ -1058,7 +1105,9 @@ async function isEmptyDir(dir) {
1058
1105
  const entries = await fs7.readdir(dir);
1059
1106
  return entries.length === 0;
1060
1107
  } catch (err) {
1061
- if (err.code === "ENOENT") return true;
1108
+ const code = err.code;
1109
+ if (code === "ENOENT") return true;
1110
+ if (code === "ENOTDIR") return false;
1062
1111
  throw err;
1063
1112
  }
1064
1113
  }
@@ -1077,25 +1126,27 @@ async function runCreate(args) {
1077
1126
  Target directory "${selections.projectName}" exists and is not empty. Aborting.`);
1078
1127
  process.exit(1);
1079
1128
  }
1129
+ const pm = detectPackageManager();
1080
1130
  const spinner2 = p2.spinner();
1081
1131
  spinner2.start("Scaffolding project");
1082
1132
  try {
1083
- await scaffoldProject(getTemplateDir(), projectDir, selections);
1133
+ await scaffoldProject(getTemplateDir(), projectDir, selections, pm);
1084
1134
  spinner2.stop("Project scaffolded");
1085
1135
  } catch (err) {
1086
1136
  spinner2.stop("Scaffolding failed");
1137
+ await fs7.rm(projectDir, { recursive: true, force: true });
1087
1138
  throw err;
1088
1139
  }
1089
1140
  let depsInstalled = false;
1090
1141
  if (args.install !== false) {
1091
1142
  const installSpinner = p2.spinner();
1092
- installSpinner.start("Installing dependencies (npm install)");
1143
+ installSpinner.start(`Installing dependencies (${pm} install)`);
1093
1144
  try {
1094
- await installDependencies(projectDir);
1145
+ await installDependencies(projectDir, pm);
1095
1146
  installSpinner.stop("Dependencies installed");
1096
1147
  depsInstalled = true;
1097
1148
  } catch {
1098
- installSpinner.stop("Dependency install skipped (run npm install manually)");
1149
+ installSpinner.stop(`Dependency install skipped (run ${pm} install manually)`);
1099
1150
  }
1100
1151
  }
1101
1152
  if (args.git !== false) {
@@ -1103,12 +1154,19 @@ Target directory "${selections.projectName}" exists and is not empty. Aborting.`
1103
1154
  }
1104
1155
  logger.success(`
1105
1156
  \u2714 Created ${pc.bold(selections.projectName)} (${optionLabel(selections.architecture)})`);
1106
- logNextSteps({ projectDir, selections, depsInstalled });
1157
+ logNextSteps({ projectDir, selections, depsInstalled, pm });
1107
1158
  }
1108
1159
 
1109
1160
  // src/index.ts
1110
1161
  var require2 = createRequire(import.meta.url);
1111
1162
  var { version } = require2("../package.json");
1163
+ var stackOptions = DIMENSIONS.map((d) => {
1164
+ const isList = d === "interfaces";
1165
+ const values = STABLE_VALUES[d].join(isList ? "," : " | ");
1166
+ const suffix = isList ? " (comma-separated; `--interfaces=` for none)" : "";
1167
+ return ` ${`--${d} <value>`.padEnd(24)} ${values}${suffix}`;
1168
+ }).join("\n");
1169
+ var defaults = DIMENSIONS.map((d) => DEFAULT_VALUES[d]).join(", ");
1112
1170
  var HELP = `
1113
1171
  create-stackrjs \u2014 scaffold a runnable full-stack Next.js project.
1114
1172
 
@@ -1116,16 +1174,16 @@ Usage:
1116
1174
  npm create stackrjs@latest [name] -- [options]
1117
1175
  npx create-stackrjs [name] [options]
1118
1176
 
1119
- Options:
1120
- --architecture <a> monolith (monorepo/microservices/bff: coming soon)
1121
- --database <db> postgres (postgres/mysql/mongodb/none)
1122
- --orm <orm> prisma (prisma/drizzle/mongoose/none \u2014 none requires --database none)
1123
- --auth <auth> nextauth (nextauth/clerk/supabase/better-auth; clerk only with --database none)
1124
- --styling <s> tailwind (tailwind/shadcn)
1125
- --interfaces <list> comma-separated (auth-pages,dashboard,admin,landing); empty for none
1177
+ Stack options:
1178
+ ${stackOptions}
1179
+
1180
+ Defaults: ${defaults}.
1181
+ Incompatible combinations are rejected with an explanation.
1182
+
1183
+ Other options:
1126
1184
  -y, --yes skip the wizard, use flags + defaults
1127
1185
  --ci non-interactive: implies --yes --no-install --no-git
1128
- --no-install do not run npm install
1186
+ --no-install do not run the dependency install
1129
1187
  --no-git do not initialize a git repository
1130
1188
  -h, --help show this help
1131
1189
  -v, --version show version
@@ -1148,6 +1206,11 @@ async function main() {
1148
1206
  await runCreate(args);
1149
1207
  }
1150
1208
  main().catch((err) => {
1151
- logger.error("\n" + (err instanceof Error ? err.stack ?? err.message : String(err)));
1209
+ if (err instanceof UsageError) {
1210
+ logger.error("\n" + err.message);
1211
+ logger.dim("Run `create-stackrjs --help` to see the available options.\n");
1212
+ } else {
1213
+ logger.error("\n" + (err instanceof Error ? err.stack ?? err.message : String(err)));
1214
+ }
1152
1215
  process.exit(1);
1153
1216
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-stackrjs",
3
- "version": "1.5.0",
3
+ "version": "2.0.0",
4
4
  "description": "Scaffold a runnable full-stack Next.js project in one command",
5
5
  "type": "module",
6
6
  "bin": {
@@ -11,7 +11,7 @@
11
11
  "template"
12
12
  ],
13
13
  "engines": {
14
- "node": ">=18.18.0"
14
+ "node": ">=20.9.0"
15
15
  },
16
16
  "scripts": {
17
17
  "build": "tsup",
@@ -21,6 +21,7 @@
21
21
  "test": "vitest run",
22
22
  "test:watch": "vitest",
23
23
  "smoke": "tsx scripts/smoke.ts",
24
+ "gen:web": "tsx scripts/gen-web-registry.ts",
24
25
  "release": "semantic-release",
25
26
  "prepublishOnly": "npm run build"
26
27
  },
@@ -1,57 +0,0 @@
1
- import type { ComponentProps, ReactNode } from "react";
2
- import Link from "next/link";
3
-
4
- /** Centered card shell shared by all auth pages. */
5
- export function AuthShell({ title, children }: { title: string; children: ReactNode }) {
6
- return (
7
- <main className="flex min-h-screen items-center justify-center bg-gray-50 p-4">
8
- <div className="w-full max-w-sm rounded-xl border border-gray-200 bg-white p-8 shadow-sm">
9
- <h1 className="mb-6 text-2xl font-semibold tracking-tight">{title}</h1>
10
- {children}
11
- </div>
12
- </main>
13
- );
14
- }
15
-
16
- export function Field({ label, ...props }: { label: string } & ComponentProps<"input">) {
17
- return (
18
- <label className="mb-4 block">
19
- <span className="mb-1 block text-sm font-medium text-gray-700">{label}</span>
20
- <input
21
- {...props}
22
- className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm outline-none focus:border-gray-900"
23
- />
24
- </label>
25
- );
26
- }
27
-
28
- export function SubmitButton({ children }: { children: ReactNode }) {
29
- return (
30
- <button
31
- type="submit"
32
- className="w-full rounded-md bg-gray-900 px-3 py-2 text-sm font-medium text-white transition hover:bg-gray-700"
33
- >
34
- {children}
35
- </button>
36
- );
37
- }
38
-
39
- export function Alert({ kind, children }: { kind: "error" | "success"; children: ReactNode }) {
40
- const styles =
41
- kind === "error"
42
- ? "bg-red-50 text-red-700 border-red-200"
43
- : "bg-green-50 text-green-700 border-green-200";
44
- return <p className={`mb-4 rounded-md border px-3 py-2 text-sm ${styles}`}>{children}</p>;
45
- }
46
-
47
- export function FooterLinks({ children }: { children: ReactNode }) {
48
- return <div className="mt-6 space-y-1 text-center text-sm text-gray-500">{children}</div>;
49
- }
50
-
51
- export function TextLink({ href, children }: { href: string; children: ReactNode }) {
52
- return (
53
- <Link href={href} className="font-medium text-gray-900 underline-offset-2 hover:underline">
54
- {children}
55
- </Link>
56
- );
57
- }
@@ -1,58 +0,0 @@
1
- import type { ComponentProps, ReactNode } from "react";
2
- import Link from "next/link";
3
-
4
- /** Centered card shell shared by all auth pages. */
5
- export function AuthShell({ title, children }: { title: string; children: ReactNode }) {
6
- return (
7
- <main className="flex min-h-screen items-center justify-center bg-gray-50 p-4">
8
- <div className="w-full max-w-sm rounded-xl border border-gray-200 bg-white p-8 shadow-sm">
9
- <h1 className="mb-6 text-2xl font-semibold tracking-tight">{title}</h1>
10
- {children}
11
- </div>
12
- </main>
13
- );
14
- }
15
-
16
- export function Field({ label, ...props }: { label: string } & ComponentProps<"input">) {
17
- return (
18
- <label className="mb-4 block">
19
- <span className="mb-1 block text-sm font-medium text-gray-700">{label}</span>
20
- <input
21
- {...props}
22
- className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm outline-none focus:border-gray-900"
23
- />
24
- </label>
25
- );
26
- }
27
-
28
- export function SubmitButton({ children, ...props }: ComponentProps<"button">) {
29
- return (
30
- <button
31
- {...props}
32
- type="submit"
33
- className="w-full rounded-md bg-gray-900 px-3 py-2 text-sm font-medium text-white transition hover:bg-gray-700 disabled:opacity-60"
34
- >
35
- {children}
36
- </button>
37
- );
38
- }
39
-
40
- export function Alert({ kind, children }: { kind: "error" | "success"; children: ReactNode }) {
41
- const styles =
42
- kind === "error"
43
- ? "bg-red-50 text-red-700 border-red-200"
44
- : "bg-green-50 text-green-700 border-green-200";
45
- return <p className={`mb-4 rounded-md border px-3 py-2 text-sm ${styles}`}>{children}</p>;
46
- }
47
-
48
- export function FooterLinks({ children }: { children: ReactNode }) {
49
- return <div className="mt-6 space-y-1 text-center text-sm text-gray-500">{children}</div>;
50
- }
51
-
52
- export function TextLink({ href, children }: { href: string; children: ReactNode }) {
53
- return (
54
- <Link href={href} className="font-medium text-gray-900 underline-offset-2 hover:underline">
55
- {children}
56
- </Link>
57
- );
58
- }