create-stackrjs 1.4.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,28 +3,53 @@
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" },
15
31
  { value: "mysql", label: "MySQL", status: "stable" },
16
32
  // MongoDB pairs with the Mongoose ODM (Prisma/Drizzle here are SQL-only).
17
- { value: "mongodb", label: "MongoDB", status: "stable", requires: { orm: ["mongoose"] } }
33
+ { value: "mongodb", label: "MongoDB", status: "stable", requires: { orm: ["mongoose"] } },
34
+ // No database: there is nothing for an ORM to talk to, so it forces "no ORM".
35
+ // NB: shares the value "none" with the ORM dimension, so keep the label generic
36
+ // (OPTION_LABELS is keyed by value across all dimensions).
37
+ { value: "none", label: "None", status: "stable", requires: { orm: ["none"] } }
18
38
  ],
19
39
  orm: [
20
- { value: "prisma", label: "Prisma", status: "stable" },
40
+ // Prisma/Drizzle/Mongoose each need a real database to sit on top of.
41
+ { value: "prisma", label: "Prisma", status: "stable", requires: { database: ["postgres", "mysql", "mongodb"] } },
21
42
  // Drizzle is SQL-only: no MongoDB.
22
43
  { value: "drizzle", label: "Drizzle", status: "stable", requires: { database: ["postgres", "mysql"] } },
23
44
  // Mongoose is MongoDB-only.
24
- { value: "mongoose", label: "Mongoose", status: "stable", requires: { database: ["mongodb"] } }
45
+ { value: "mongoose", label: "Mongoose", status: "stable", requires: { database: ["mongodb"] } },
46
+ // No data layer: only valid when there is no database either.
47
+ { value: "none", label: "None", status: "stable", requires: { database: ["none"] } }
25
48
  ],
26
49
  auth: [
27
- { value: "nextauth", label: "NextAuth (Auth.js)", status: "stable" },
50
+ // NextAuth's credentials provider reads/writes users through an ORM overlay,
51
+ // so it needs a data layer (Prisma / Drizzle / Mongoose).
52
+ { value: "nextauth", label: "NextAuth (Auth.js)", status: "stable", requires: { orm: ["prisma", "drizzle", "mongoose"] } },
28
53
  // Clerk is hosted auth, independent of the chosen ORM/database.
29
54
  { value: "clerk", label: "Clerk", status: "stable" },
30
55
  // Supabase Auth is backed by a Supabase Postgres project.
@@ -70,9 +95,6 @@ var OPTION_LABELS = Object.fromEntries(
70
95
  DIMENSIONS.flatMap((d) => REGISTRY[d].map((o) => [o.value, o.label]))
71
96
  );
72
97
  var optionLabel = (value) => OPTION_LABELS[value] ?? value;
73
- var ALL_VALUES = Object.fromEntries(
74
- DIMENSIONS.map((d) => [d, REGISTRY[d].map((o) => o.value)])
75
- );
76
98
  var STABLE_VALUES = Object.fromEntries(
77
99
  DIMENSIONS.map((d) => [d, REGISTRY[d].filter((o) => o.status === "stable").map((o) => o.value)])
78
100
  );
@@ -84,75 +106,48 @@ function findOption(dimension, value) {
84
106
  }
85
107
 
86
108
  // src/cli/flags.ts
87
- var SUPPORTED = STABLE_VALUES;
88
- var VALUE_FLAGS = /* @__PURE__ */ new Set([
89
- "architecture",
90
- "database",
91
- "orm",
92
- "auth",
93
- "styling",
94
- "interfaces"
95
- ]);
96
- function parseArgs(argv) {
97
- const result = { positionals: [], yes: false, help: false, version: false };
98
- const positionals = [];
99
- for (let i = 0; i < argv.length; i++) {
100
- const token = argv[i];
101
- if (!token.startsWith("-")) {
102
- positionals.push(token);
103
- continue;
104
- }
105
- let key = token.replace(/^--?/, "");
106
- let value;
107
- const eq = key.indexOf("=");
108
- if (eq !== -1) {
109
- value = key.slice(eq + 1);
110
- key = key.slice(0, eq);
111
- }
112
- switch (key) {
113
- case "help":
114
- case "h":
115
- result.help = true;
116
- break;
117
- case "version":
118
- case "v":
119
- result.version = true;
120
- break;
121
- case "yes":
122
- case "y":
123
- result.yes = true;
124
- break;
125
- case "ci":
126
- result.yes = true;
127
- result.install = false;
128
- result.git = false;
129
- break;
130
- case "install":
131
- result.install = true;
132
- break;
133
- case "no-install":
134
- result.install = false;
135
- break;
136
- case "git":
137
- result.git = true;
138
- break;
139
- case "no-git":
140
- result.git = false;
141
- break;
142
- default: {
143
- if (VALUE_FLAGS.has(key)) {
144
- if (value === void 0) value = argv[++i];
145
- if (key === "interfaces") {
146
- result.interfaces = (value ?? "").split(",").map((s) => s.trim()).filter(Boolean);
147
- } else {
148
- result[key] = value;
149
- }
150
- }
151
- }
152
- }
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));
153
129
  }
154
- result.positionals = positionals;
155
- 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
+ };
156
151
  }
157
152
 
158
153
  // src/cli/create.ts
@@ -171,13 +166,32 @@ function getTemplateDir() {
171
166
  // src/utils/logger.ts
172
167
  import pc from "picocolors";
173
168
  var logger = {
174
- info: (msg) => console.log(msg),
175
169
  success: (msg) => console.log(pc.green(msg)),
176
- warn: (msg) => console.log(pc.yellow(msg)),
177
170
  error: (msg) => console.error(pc.red(msg)),
178
171
  dim: (msg) => console.log(pc.dim(msg))
179
172
  };
180
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
+
181
195
  // src/helpers/scaffoldProject.ts
182
196
  import { promises as fs6 } from "fs";
183
197
  import path20 from "path";
@@ -479,57 +493,46 @@ var supabaseInstaller = async (ctx) => {
479
493
  });
480
494
  };
481
495
 
482
- // src/installers/tailwind.ts
496
+ // src/installers/styling.ts
483
497
  import path13 from "path";
484
498
  var TAILWIND_DEV_DEPS = {
485
499
  tailwindcss: "^3.4.17",
486
500
  postcss: "^8.4.49",
487
501
  autoprefixer: "^10.4.20"
488
502
  };
489
- var tailwindInstaller = async (ctx) => {
490
- const webDir = path13.join(ctx.projectDir, ctx.layout.webRoot);
491
- await copyTemplate(path13.join(ctx.templateDir, "extras", "tailwind"), webDir);
492
- if (ctx.layout.multiService) {
493
- await mergePackageDependencies(path13.join(webDir, "package.json"), {}, TAILWIND_DEV_DEPS);
494
- } else {
495
- addDevDependencies(ctx.pkg, TAILWIND_DEV_DEPS);
496
- }
497
- };
498
-
499
- // src/installers/shadcn.ts
500
- import path14 from "path";
501
503
  var SHADCN_DEPS = {
502
504
  "class-variance-authority": "^0.7.1",
503
505
  clsx: "^2.1.1",
504
506
  "tailwind-merge": "^2.6.0",
505
507
  "lucide-react": "^0.469.0"
506
508
  };
507
- var SHADCN_DEV_DEPS = {
508
- tailwindcss: "^3.4.17",
509
- postcss: "^8.4.49",
510
- autoprefixer: "^10.4.20",
511
- "tailwindcss-animate": "^1.0.7"
512
- };
513
- var shadcnInstaller = async (ctx) => {
514
- const webDir = path14.join(ctx.projectDir, ctx.layout.webRoot);
515
- 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);
516
513
  if (ctx.layout.multiService) {
517
- await mergePackageDependencies(path14.join(webDir, "package.json"), SHADCN_DEPS, SHADCN_DEV_DEPS);
518
- } else {
519
- addDependencies(ctx.pkg, SHADCN_DEPS);
520
- addDevDependencies(ctx.pkg, SHADCN_DEV_DEPS);
514
+ await mergePackageDependencies(path13.join(webDir, "package.json"), deps, devDeps);
515
+ return;
521
516
  }
522
- };
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);
523
522
 
524
523
  // src/installers/auth-pages.ts
525
- import path15 from "path";
524
+ import path14 from "path";
526
525
  var authPagesInstaller = async (ctx) => {
527
526
  if (ctx.selections.auth !== "nextauth") return;
528
- await copyTemplate(path15.join(ctx.templateDir, "extras", "auth-pages"), ctx.projectDir);
527
+ await copyTemplate(path14.join(ctx.templateDir, "extras", "auth-pages"), ctx.projectDir);
529
528
  const overlay = `auth-pages-${ctx.selections.orm}`;
530
- await copyTemplate(path15.join(ctx.templateDir, "extras", overlay), ctx.projectDir);
529
+ await copyTemplate(path14.join(ctx.templateDir, "extras", overlay), ctx.projectDir);
531
530
  };
532
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
+
533
536
  // src/installers/account.ts
534
537
  import path16 from "path";
535
538
  var accountAdapterInstaller = async (ctx) => {
@@ -598,7 +601,7 @@ function resolveInstallers(selections) {
598
601
  if (selections.styling === "shadcn") installers.push(shadcnInstaller);
599
602
  return installers;
600
603
  }
601
- installers.push(databaseInstaller);
604
+ if (selections.database !== "none") installers.push(databaseInstaller);
602
605
  if (selections.orm === "prisma") installers.push(prismaInstaller);
603
606
  if (selections.orm === "drizzle") installers.push(drizzleInstaller);
604
607
  if (selections.orm === "mongoose") installers.push(mongooseInstaller);
@@ -606,6 +609,8 @@ function resolveInstallers(selections) {
606
609
  if (selections.auth === "better-auth") installers.push(betterAuthInstaller);
607
610
  if (selections.auth === "clerk") installers.push(clerkInstaller);
608
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);
609
614
  if (selections.styling === "tailwind") installers.push(tailwindInstaller);
610
615
  if (selections.styling === "shadcn") installers.push(shadcnInstaller);
611
616
  if (selections.interfaces.includes("auth-pages")) installers.push(authPagesInstaller);
@@ -627,16 +632,122 @@ function getLayout(architecture) {
627
632
  return LAYOUTS[architecture];
628
633
  }
629
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
+
630
718
  // src/helpers/generateReadme.ts
631
- function generateReadme(selections) {
632
- const interfaces = selections.interfaces.length ? selections.interfaces.map((i) => `- ${optionLabel(i)}`).join("\n") : "- _(none)_";
633
- const dbEngine = { postgres: "Postgres", mysql: "MySQL", mongodb: "MongoDB" }[selections.database] ?? "Postgres";
634
- 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);
635
721
  return `# ${selections.projectName}
636
722
 
637
- 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.
638
724
 
639
- ## Stack
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
744
+
745
+ # 2. Install dependencies
746
+ ${pm} install
747
+ ` : `# 1. Install dependencies
748
+ ${pm} install
749
+ `;
750
+ return `## Stack
640
751
 
641
752
  - **Architecture:** ${optionLabel(selections.architecture)}
642
753
  - **Database:** ${optionLabel(selections.database)}
@@ -648,22 +759,12 @@ Generated with [Stackr](https://github.com/) \u2014 a runnable full-stack Next.j
648
759
 
649
760
  ${interfaces}
650
761
 
651
- ${gettingStarted}`;
652
- }
653
- function monolithGettingStarted(dbEngine, orm) {
654
- 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" : "";
655
- 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" : "";
656
- return `## Getting started
762
+ ## Getting started
657
763
 
658
764
  \`\`\`bash
659
- # 1. Start a local ${dbEngine} (requires Docker)
660
- docker compose up -d
661
-
662
- # 2. Install dependencies
663
- npm install
664
- ${schemaStep}
765
+ ${dbStep}${schemaStep}
665
766
  # Run the app
666
- npm run dev
767
+ ${pm} run dev
667
768
  \`\`\`
668
769
 
669
770
  Then open [http://localhost:3000](http://localhost:3000).
@@ -680,15 +781,19 @@ generated \`AUTH_SECRET\` was already created for you.
680
781
 
681
782
  | Script | Description |
682
783
  | ------ | ----------- |
683
- | \`npm run dev\` | Start the dev server |
684
- | \`npm run build\` | Production build |
685
- ${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 |
686
787
  `;
687
788
  }
688
- function microservicesGettingStarted() {
689
- return `## Architecture
789
+ function microservicesBody(selections, pm) {
790
+ return `## Stack
690
791
 
691
- 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)}
692
797
 
693
798
  | Path | Service | Description |
694
799
  | ---- | ------- | ----------- |
@@ -706,13 +811,13 @@ service (prefix stripped) and all other routes to the web app.
706
811
  docker compose up -d db
707
812
 
708
813
  # 2. Install dependencies for every workspace
709
- npm install
814
+ ${pm} install
710
815
 
711
816
  # 3. Create the database schema (runs in services/api)
712
- npm run db:migrate
817
+ ${pm} run db:migrate
713
818
 
714
819
  # 4. Run the web app and the API together
715
- npm run dev
820
+ ${pm} run dev
716
821
  \`\`\`
717
822
 
718
823
  - web: [http://localhost:3000](http://localhost:3000) \xB7 api: [http://localhost:3001](http://localhost:3001)
@@ -736,11 +841,11 @@ generated \`AUTH_SECRET\` was already created for you.
736
841
 
737
842
  | Script | Description |
738
843
  | ------ | ----------- |
739
- | \`npm run dev\` | Start every workspace (web + api) |
740
- | \`npm run build\` | Build every workspace |
741
- | \`npm run db:migrate\` | Run Prisma migrations in the API service |
742
- | \`npm run db:studio\` | Open Prisma Studio for the API database |
743
- | \`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 |
744
849
  `;
745
850
  }
746
851
 
@@ -751,14 +856,11 @@ function architectureBase(templateDir, selections) {
751
856
  }
752
857
  return path20.join(templateDir, "base");
753
858
  }
754
- function withImpliedInterfaces(selections) {
755
- const wantsAccount = selections.interfaces.includes("dashboard") || selections.interfaces.includes("admin");
756
- const needsPortal = selections.auth === "nextauth" && wantsAccount && !selections.interfaces.includes("auth-pages");
757
- if (!needsPortal) return selections;
758
- return { ...selections, interfaces: ["auth-pages", ...selections.interfaces] };
759
- }
760
- async function scaffoldProject(templateDir, projectDir, inputSelections) {
761
- 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
+ };
762
864
  await fs6.mkdir(projectDir, { recursive: true });
763
865
  await copyTemplate(architectureBase(templateDir, selections), projectDir);
764
866
  const pkgPath = path20.join(projectDir, "package.json");
@@ -774,25 +876,25 @@ async function scaffoldProject(templateDir, projectDir, inputSelections) {
774
876
  await writeEnvFiles(projectDir, env);
775
877
  await fs6.writeFile(
776
878
  path20.join(projectDir, "README.md"),
777
- generateReadme(selections),
879
+ generateReadme(selections, pm),
778
880
  "utf8"
779
881
  );
780
882
  }
781
883
 
782
884
  // src/helpers/installDependencies.ts
783
885
  import { spawn } from "child_process";
784
- function installDependencies(projectDir) {
886
+ function installDependencies(projectDir, pm) {
785
887
  return new Promise((resolve, reject) => {
786
- const child = spawn("npm", ["install"], {
888
+ const child = spawn(pm, ["install"], {
787
889
  cwd: projectDir,
788
890
  stdio: "inherit",
789
- // npm is a .cmd shim on Windows; shell:true resolves it correctly.
891
+ // Package managers are .cmd shims on Windows; shell:true resolves them.
790
892
  shell: process.platform === "win32"
791
893
  });
792
894
  child.on("error", reject);
793
895
  child.on("close", (code) => {
794
896
  if (code === 0) resolve();
795
- else reject(new Error(`npm install exited with code ${code}`));
897
+ else reject(new Error(`${pm} install exited with code ${code}`));
796
898
  });
797
899
  });
798
900
  }
@@ -823,98 +925,43 @@ async function initGit(projectDir) {
823
925
 
824
926
  // src/helpers/logNextSteps.ts
825
927
  import path21 from "path";
826
- function logNextSteps({ projectDir, selections, depsInstalled }) {
827
- const rel = path21.relative(process.cwd(), projectDir) || ".";
828
- 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) || "."}`);
829
932
  if (selections.architecture === "microservices") {
830
- steps.push("docker compose up -d db # start local Postgres");
831
- if (!depsInstalled) steps.push("npm install # installs every workspace");
832
- steps.push("npm run db:migrate # migrate the API database");
833
- 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");
834
937
  } else {
835
- const engineLabel = {
836
- postgres: "Postgres",
837
- mysql: "MySQL",
838
- mongodb: "MongoDB"
839
- };
840
- if (engineLabel[selections.database]) {
841
- steps.push(`docker compose up -d # start local ${engineLabel[selections.database]}`);
842
- }
843
- if (!depsInstalled) {
844
- steps.push("npm install");
938
+ if (selections.database !== "none") {
939
+ step("docker compose up -d", `start local ${optionLabel(selections.database)}`);
845
940
  }
941
+ if (!depsInstalled) step(`${pm} install`);
846
942
  if (selections.orm === "prisma") {
847
- steps.push("npx prisma migrate dev --name init");
943
+ step(`${execCommand(pm)} prisma migrate dev --name init`);
848
944
  } else if (selections.orm === "drizzle") {
849
- steps.push("npm run db:push # create the schema with Drizzle");
945
+ step(`${pm} run db:push`, "create the schema with Drizzle");
850
946
  }
851
- steps.push("npm run dev");
947
+ step(`${pm} run dev`);
852
948
  }
853
949
  console.log("");
854
950
  console.log(pc.bold("Next steps:"));
855
- for (const step of steps) {
856
- console.log(" " + pc.cyan(step));
857
- }
951
+ for (const s of steps) console.log(" " + pc.cyan(s));
858
952
  console.log("");
859
- return steps;
860
953
  }
861
954
 
862
955
  // src/cli/wizard.ts
863
956
  import * as p from "@clack/prompts";
864
957
 
865
- // src/shared/compat.ts
866
- function selectedValues(selection, dimension) {
867
- const raw = selection[dimension];
868
- if (raw === void 0) return [];
869
- return Array.isArray(raw) ? raw : [raw];
870
- }
871
- function conflict(selection, dimension, value) {
872
- const own = findOption(dimension, value);
873
- for (const other of DIMENSIONS) {
874
- if (other === dimension) continue;
875
- for (const otherValue of selectedValues(selection, other)) {
876
- const forward = own?.requires?.[other];
877
- if (forward && !forward.includes(otherValue)) return [other, otherValue];
878
- const backward = findOption(other, otherValue)?.requires?.[dimension];
879
- if (backward && !backward.includes(value)) return [other, otherValue];
880
- }
881
- }
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 '.'.";
882
963
  return void 0;
883
964
  }
884
- function resolveAvailability(selection) {
885
- const out = {};
886
- for (const dimension of DIMENSIONS) {
887
- out[dimension] = REGISTRY[dimension].map((option) => {
888
- const clash = conflict(selection, dimension, option.value);
889
- const generatable = option.status === "stable";
890
- let reason;
891
- if (clash) {
892
- reason = `incompatible with ${clash[0]} "${optionLabel(clash[1])}"`;
893
- } else if (!generatable) {
894
- reason = "coming soon";
895
- }
896
- return { value: option.value, enabled: !clash, generatable, reason };
897
- });
898
- }
899
- return out;
900
- }
901
- function validateCompat(selection) {
902
- const errors = [];
903
- const seen = /* @__PURE__ */ new Set();
904
- for (const dimension of DIMENSIONS) {
905
- for (const value of selectedValues(selection, dimension)) {
906
- const clash = conflict(selection, dimension, value);
907
- if (!clash) continue;
908
- const key = [dimension, value, clash[0], clash[1]].sort().join("|");
909
- if (seen.has(key)) continue;
910
- seen.add(key);
911
- errors.push(
912
- `${dimension} "${optionLabel(value)}" is incompatible with ${clash[0]} "${optionLabel(clash[1])}".`
913
- );
914
- }
915
- }
916
- return errors;
917
- }
918
965
 
919
966
  // src/cli/wizard.ts
920
967
  function bail() {
@@ -936,7 +983,16 @@ function isSelectable(dimension, value, selection) {
936
983
  const a = resolveAvailability(selection)[dimension].find((x) => x.value === value);
937
984
  return a.enabled && a.generatable;
938
985
  }
986
+ function selectable(dimension, selection) {
987
+ return resolveAvailability(selection)[dimension].filter((a) => a.enabled && a.generatable).map((a) => a.value);
988
+ }
939
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
+ }
940
996
  for (; ; ) {
941
997
  const value = unwrap(
942
998
  await p.select({
@@ -950,18 +1006,14 @@ async function selectDimension(message, dimension, selection) {
950
1006
  p.log.warn(`"${optionLabel(value)}" is ${reason}. Please pick another.`);
951
1007
  }
952
1008
  }
953
- async function runWizard(defaults) {
1009
+ async function runWizard(defaults2) {
954
1010
  p.intro("Stackr \u2014 scaffold a runnable full-stack Next.js project");
955
1011
  const projectName = unwrap(
956
1012
  await p.text({
957
1013
  message: "Project name?",
958
1014
  placeholder: "my-app",
959
- initialValue: defaults.projectName,
960
- validate: (v) => {
961
- if (!v) return "Please enter a project name.";
962
- if (!/^[a-z0-9][a-z0-9-_.]*$/i.test(v)) return "Use letters, numbers, '-', '_' or '.'.";
963
- return void 0;
964
- }
1015
+ initialValue: defaults2.projectName,
1016
+ validate: validateProjectName
965
1017
  })
966
1018
  );
967
1019
  const selection = {};
@@ -970,20 +1022,24 @@ async function runWizard(defaults) {
970
1022
  selection.orm = await selectDimension("ORM / data layer?", "orm", selection);
971
1023
  selection.auth = await selectDimension("Authentication?", "auth", selection);
972
1024
  selection.styling = await selectDimension("Styling?", "styling", selection);
973
- const picked = unwrap(
974
- await p.multiselect({
975
- message: "Pre-built interfaces? (space to toggle)",
976
- options: buildOptions("interfaces", selection),
977
- initialValues: ["auth-pages"],
978
- required: false
979
- })
980
- );
981
1025
  const interfaces = [];
982
- for (const value of picked) {
983
- if (isSelectable("interfaces", value, selection)) interfaces.push(value);
984
- else {
985
- const reason = resolveAvailability(selection).interfaces.find((x) => x.value === value).reason;
986
- 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
+ }
987
1043
  }
988
1044
  }
989
1045
  selection.interfaces = interfaces;
@@ -1005,22 +1061,31 @@ function projectNameFromArgs(args) {
1005
1061
  return positionals[0];
1006
1062
  }
1007
1063
  function selectionsFromFlags(args) {
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];
1008
1071
  return {
1009
1072
  projectName: projectNameFromArgs(args) ?? "my-app",
1010
- architecture: args.architecture ?? "monolith",
1011
- database: args.database ?? "postgres",
1012
- orm: args.orm ?? "prisma",
1013
- auth: args.auth ?? "nextauth",
1014
- styling: args.styling ?? "tailwind",
1015
- 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
1016
1079
  };
1017
1080
  }
1018
1081
  function validateSelections(s) {
1019
1082
  const errors = [];
1083
+ const nameError = validateProjectName(s.projectName);
1084
+ if (nameError) errors.push(`project name "${s.projectName}" is invalid. ${nameError}`);
1020
1085
  const check = (dim, value) => {
1021
- if (!SUPPORTED[dim].includes(value)) {
1086
+ if (!STABLE_VALUES[dim].includes(value)) {
1022
1087
  errors.push(
1023
- `${dim} "${value}" is not supported yet (currently generatable: ${SUPPORTED[dim].join(", ")}).`
1088
+ `${dim} "${value}" is not supported yet (currently generatable: ${STABLE_VALUES[dim].join(", ")}).`
1024
1089
  );
1025
1090
  }
1026
1091
  };
@@ -1040,7 +1105,9 @@ async function isEmptyDir(dir) {
1040
1105
  const entries = await fs7.readdir(dir);
1041
1106
  return entries.length === 0;
1042
1107
  } catch (err) {
1043
- if (err.code === "ENOENT") return true;
1108
+ const code = err.code;
1109
+ if (code === "ENOENT") return true;
1110
+ if (code === "ENOTDIR") return false;
1044
1111
  throw err;
1045
1112
  }
1046
1113
  }
@@ -1059,25 +1126,27 @@ async function runCreate(args) {
1059
1126
  Target directory "${selections.projectName}" exists and is not empty. Aborting.`);
1060
1127
  process.exit(1);
1061
1128
  }
1129
+ const pm = detectPackageManager();
1062
1130
  const spinner2 = p2.spinner();
1063
1131
  spinner2.start("Scaffolding project");
1064
1132
  try {
1065
- await scaffoldProject(getTemplateDir(), projectDir, selections);
1133
+ await scaffoldProject(getTemplateDir(), projectDir, selections, pm);
1066
1134
  spinner2.stop("Project scaffolded");
1067
1135
  } catch (err) {
1068
1136
  spinner2.stop("Scaffolding failed");
1137
+ await fs7.rm(projectDir, { recursive: true, force: true });
1069
1138
  throw err;
1070
1139
  }
1071
1140
  let depsInstalled = false;
1072
1141
  if (args.install !== false) {
1073
1142
  const installSpinner = p2.spinner();
1074
- installSpinner.start("Installing dependencies (npm install)");
1143
+ installSpinner.start(`Installing dependencies (${pm} install)`);
1075
1144
  try {
1076
- await installDependencies(projectDir);
1145
+ await installDependencies(projectDir, pm);
1077
1146
  installSpinner.stop("Dependencies installed");
1078
1147
  depsInstalled = true;
1079
1148
  } catch {
1080
- installSpinner.stop("Dependency install skipped (run npm install manually)");
1149
+ installSpinner.stop(`Dependency install skipped (run ${pm} install manually)`);
1081
1150
  }
1082
1151
  }
1083
1152
  if (args.git !== false) {
@@ -1085,12 +1154,19 @@ Target directory "${selections.projectName}" exists and is not empty. Aborting.`
1085
1154
  }
1086
1155
  logger.success(`
1087
1156
  \u2714 Created ${pc.bold(selections.projectName)} (${optionLabel(selections.architecture)})`);
1088
- logNextSteps({ projectDir, selections, depsInstalled });
1157
+ logNextSteps({ projectDir, selections, depsInstalled, pm });
1089
1158
  }
1090
1159
 
1091
1160
  // src/index.ts
1092
1161
  var require2 = createRequire(import.meta.url);
1093
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(", ");
1094
1170
  var HELP = `
1095
1171
  create-stackrjs \u2014 scaffold a runnable full-stack Next.js project.
1096
1172
 
@@ -1098,16 +1174,16 @@ Usage:
1098
1174
  npm create stackrjs@latest [name] -- [options]
1099
1175
  npx create-stackrjs [name] [options]
1100
1176
 
1101
- Options:
1102
- --architecture <a> monolith (monorepo/microservices/bff: coming soon)
1103
- --database <db> postgres (mysql/sqlite/mongodb: coming soon)
1104
- --orm <orm> prisma (drizzle/mongoose: coming soon)
1105
- --auth <auth> nextauth (clerk/supabase/jwt: coming soon)
1106
- --styling <s> tailwind (css-modules/styled-components/shadcn: coming soon)
1107
- --interfaces <list> comma-separated; v1: auth-pages
1177
+ Stack options:
1178
+ ${stackOptions}
1179
+
1180
+ Defaults: ${defaults}.
1181
+ Incompatible combinations are rejected with an explanation.
1182
+
1183
+ Other options:
1108
1184
  -y, --yes skip the wizard, use flags + defaults
1109
1185
  --ci non-interactive: implies --yes --no-install --no-git
1110
- --no-install do not run npm install
1186
+ --no-install do not run the dependency install
1111
1187
  --no-git do not initialize a git repository
1112
1188
  -h, --help show this help
1113
1189
  -v, --version show version
@@ -1130,6 +1206,11 @@ async function main() {
1130
1206
  await runCreate(args);
1131
1207
  }
1132
1208
  main().catch((err) => {
1133
- 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
+ }
1134
1215
  process.exit(1);
1135
1216
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-stackrjs",
3
- "version": "1.4.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
- }