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 +23 -8
- package/dist/index.js +355 -274
- package/package.json +3 -2
- package/template/extras/auth-pages/src/components/auth/ui.tsx +0 -57
- package/template/extras/supabase/src/components/auth/ui.tsx +0 -58
- /package/template/extras/{better-auth → auth-ui}/src/components/auth/ui.tsx +0 -0
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
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
|
11
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
"
|
|
94
|
-
"
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
155
|
-
|
|
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/
|
|
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
|
-
|
|
509
|
-
|
|
510
|
-
|
|
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(
|
|
518
|
-
|
|
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
|
|
524
|
+
import path14 from "path";
|
|
526
525
|
var authPagesInstaller = async (ctx) => {
|
|
527
526
|
if (ctx.selections.auth !== "nextauth") return;
|
|
528
|
-
await copyTemplate(
|
|
527
|
+
await copyTemplate(path14.join(ctx.templateDir, "extras", "auth-pages"), ctx.projectDir);
|
|
529
528
|
const overlay = `auth-pages-${ctx.selections.orm}`;
|
|
530
|
-
await copyTemplate(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
660
|
-
docker compose up -d
|
|
661
|
-
|
|
662
|
-
# 2. Install dependencies
|
|
663
|
-
npm install
|
|
664
|
-
${schemaStep}
|
|
765
|
+
${dbStep}${schemaStep}
|
|
665
766
|
# Run the app
|
|
666
|
-
|
|
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
|
-
|
|
|
684
|
-
|
|
|
685
|
-
${dbScriptRows}|
|
|
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
|
|
689
|
-
return `##
|
|
789
|
+
function microservicesBody(selections, pm) {
|
|
790
|
+
return `## Stack
|
|
690
791
|
|
|
691
|
-
|
|
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
|
-
|
|
814
|
+
${pm} install
|
|
710
815
|
|
|
711
816
|
# 3. Create the database schema (runs in services/api)
|
|
712
|
-
|
|
817
|
+
${pm} run db:migrate
|
|
713
818
|
|
|
714
819
|
# 4. Run the web app and the API together
|
|
715
|
-
|
|
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
|
-
|
|
|
740
|
-
|
|
|
741
|
-
|
|
|
742
|
-
|
|
|
743
|
-
|
|
|
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
|
|
755
|
-
const
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
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(
|
|
888
|
+
const child = spawn(pm, ["install"], {
|
|
787
889
|
cwd: projectDir,
|
|
788
890
|
stdio: "inherit",
|
|
789
|
-
//
|
|
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(
|
|
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
|
|
828
|
-
const
|
|
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
|
-
|
|
831
|
-
if (!depsInstalled)
|
|
832
|
-
|
|
833
|
-
|
|
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
|
-
|
|
836
|
-
|
|
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
|
-
|
|
943
|
+
step(`${execCommand(pm)} prisma migrate dev --name init`);
|
|
848
944
|
} else if (selections.orm === "drizzle") {
|
|
849
|
-
|
|
945
|
+
step(`${pm} run db:push`, "create the schema with Drizzle");
|
|
850
946
|
}
|
|
851
|
-
|
|
947
|
+
step(`${pm} run dev`);
|
|
852
948
|
}
|
|
853
949
|
console.log("");
|
|
854
950
|
console.log(pc.bold("Next steps:"));
|
|
855
|
-
for (const
|
|
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/
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
if (
|
|
869
|
-
|
|
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(
|
|
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:
|
|
960
|
-
validate:
|
|
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
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
p.
|
|
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:
|
|
1011
|
-
database:
|
|
1012
|
-
orm:
|
|
1013
|
-
auth:
|
|
1014
|
-
styling:
|
|
1015
|
-
interfaces:
|
|
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 (!
|
|
1086
|
+
if (!STABLE_VALUES[dim].includes(value)) {
|
|
1022
1087
|
errors.push(
|
|
1023
|
-
`${dim} "${value}" is not supported yet (currently generatable: ${
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
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
|
|
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
|
-
|
|
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": "
|
|
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": ">=
|
|
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
|
-
}
|
|
File without changes
|