@pauldvlp/vp-react-ts-nestjs 0.1.0 → 0.2.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 +3 -5
- package/dist/index.js +193 -35
- package/package.json +2 -3
- package/template/apps/api/package.json +1 -1
- package/template/apps/api/src/common/zod-validation.pipe.ts +3 -3
- package/template/apps/api/src/config/env.ts +3 -3
- package/template/packages/contracts/package.json +1 -1
- package/template/packages/contracts/src/index.ts +5 -4
package/README.md
CHANGED
|
@@ -16,8 +16,7 @@ The api rides Vite+'s native **Oxc** transform, which emits `emitDecoratorMetada
|
|
|
16
16
|
the production build is a Vite SSR bundle (`dist/main.js`), and a single `vp check` / `vp test` /
|
|
17
17
|
`vp run -r build` covers the whole workspace.
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
(anything after `--`).
|
|
19
|
+
Options can be passed on the `vp create` command line (anything after `--`) or answered interactively.
|
|
21
20
|
|
|
22
21
|
## Usage
|
|
23
22
|
|
|
@@ -32,9 +31,8 @@ vp create @pauldvlp:vp-react-ts-nestjs -- \
|
|
|
32
31
|
--name my-app --scope @acme --apiPort 3000 --webPort 5173
|
|
33
32
|
```
|
|
34
33
|
|
|
35
|
-
>
|
|
36
|
-
>
|
|
37
|
-
> CLI does not accept `--no-x` / `--x=false` cleanly. Omit them and answer the prompt.
|
|
34
|
+
> **Boolean** options (`--swagger`, `--serveWeb`, `--docker`, `--install`) accept both forms: pass
|
|
35
|
+
> `--swagger` to enable or `--no-swagger` to disable. Omit any option to answer it at the interactive prompt.
|
|
38
36
|
|
|
39
37
|
## Options
|
|
40
38
|
|
package/dist/index.js
CHANGED
|
@@ -1,15 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
// bin/index.ts
|
|
4
|
-
import { runTemplateCLI } from "bingo";
|
|
5
|
-
|
|
6
|
-
// src/template.ts
|
|
7
|
-
import path2 from "node:path";
|
|
8
|
-
import { fileURLToPath } from "node:url";
|
|
9
|
-
|
|
10
3
|
// ../template-kit/src/index.ts
|
|
4
|
+
import { execFileSync } from "node:child_process";
|
|
11
5
|
import fs from "node:fs";
|
|
12
6
|
import path from "node:path";
|
|
7
|
+
import * as prompts from "@clack/prompts";
|
|
13
8
|
var RENAME = {
|
|
14
9
|
_gitignore: ".gitignore",
|
|
15
10
|
_dockerignore: ".dockerignore",
|
|
@@ -19,6 +14,10 @@ function toScope(name) {
|
|
|
19
14
|
const n = name.trim();
|
|
20
15
|
return n.startsWith("@") ? n : `@${n}`;
|
|
21
16
|
}
|
|
17
|
+
function dirName(directory) {
|
|
18
|
+
const base = path.basename(directory || "");
|
|
19
|
+
return base && base !== "." ? base : "my-app";
|
|
20
|
+
}
|
|
22
21
|
function readTree(dir, transform, base = dir) {
|
|
23
22
|
const out = {};
|
|
24
23
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
@@ -43,15 +42,178 @@ function patchJson(tree, relPath, mutate) {
|
|
|
43
42
|
node[key] = `${JSON.stringify(json, null, 2)}
|
|
44
43
|
`;
|
|
45
44
|
}
|
|
45
|
+
function defineTemplate(config) {
|
|
46
|
+
return config;
|
|
47
|
+
}
|
|
48
|
+
function writeTree(node, dir) {
|
|
49
|
+
for (const [name, value] of Object.entries(node)) {
|
|
50
|
+
const p = path.join(dir, name);
|
|
51
|
+
if (typeof value === "string") {
|
|
52
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
53
|
+
fs.writeFileSync(p, value);
|
|
54
|
+
} else {
|
|
55
|
+
fs.mkdirSync(p, { recursive: true });
|
|
56
|
+
writeTree(value, p);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function parseArgv(argv, descriptors) {
|
|
61
|
+
const booleanKeys = new Set(descriptors.filter((d) => d.type === "boolean").map((d) => d.key));
|
|
62
|
+
const values = {};
|
|
63
|
+
let directory;
|
|
64
|
+
for (let i = 0; i < argv.length; i++) {
|
|
65
|
+
const arg = argv[i];
|
|
66
|
+
if (!arg.startsWith("--")) continue;
|
|
67
|
+
let body = arg.slice(2);
|
|
68
|
+
if (body.startsWith("no-") && booleanKeys.has(body.slice(3))) {
|
|
69
|
+
values[body.slice(3)] = false;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
let inline;
|
|
73
|
+
const eq = body.indexOf("=");
|
|
74
|
+
if (eq !== -1) {
|
|
75
|
+
inline = body.slice(eq + 1);
|
|
76
|
+
body = body.slice(0, eq);
|
|
77
|
+
}
|
|
78
|
+
if (body === "directory") {
|
|
79
|
+
directory = inline ?? argv[++i];
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (booleanKeys.has(body)) {
|
|
83
|
+
values[body] = inline === void 0 ? true : inline !== "false";
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
values[body] = inline ?? argv[++i] ?? "";
|
|
87
|
+
}
|
|
88
|
+
return { values, directory };
|
|
89
|
+
}
|
|
90
|
+
function resolveDefault(d, options, directory) {
|
|
91
|
+
return typeof d.default === "function" ? d.default({ options, directory }) : d.default;
|
|
92
|
+
}
|
|
93
|
+
async function promptForOption(d, def) {
|
|
94
|
+
if (d.type === "boolean") {
|
|
95
|
+
return prompts.confirm({ message: d.message, initialValue: def === void 0 ? false : Boolean(def) });
|
|
96
|
+
}
|
|
97
|
+
if (d.type === "select") {
|
|
98
|
+
return prompts.select({
|
|
99
|
+
message: d.message,
|
|
100
|
+
options: (d.choices ?? []).map((c) => ({ value: c, label: c })),
|
|
101
|
+
initialValue: def
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
const fallback = def === void 0 ? void 0 : String(def);
|
|
105
|
+
return prompts.text({
|
|
106
|
+
message: d.message,
|
|
107
|
+
placeholder: fallback,
|
|
108
|
+
defaultValue: fallback,
|
|
109
|
+
// When a default exists, an empty submit means "use the default" — don't run validation on it,
|
|
110
|
+
// otherwise a rule like the port check would reject pressing Enter to accept 3000.
|
|
111
|
+
validate: d.validate ? (value) => !value && fallback !== void 0 ? void 0 : d.validate(value ?? "") : void 0
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
function printHelp(template) {
|
|
115
|
+
const lines = [];
|
|
116
|
+
if (template.about?.name) lines.push(template.about.name);
|
|
117
|
+
if (template.about?.description) lines.push(template.about.description);
|
|
118
|
+
lines.push("", "Options:", " --directory <dir> Where to scaffold");
|
|
119
|
+
for (const d of template.options) {
|
|
120
|
+
const hint = d.type === "select" && d.choices ? ` <${d.choices.join("|")}>` : d.type === "boolean" ? ` / --no-${d.key}` : " <value>";
|
|
121
|
+
lines.push(` --${d.key}${hint} ${d.message}`);
|
|
122
|
+
}
|
|
123
|
+
console.log(lines.join("\n"));
|
|
124
|
+
}
|
|
125
|
+
function initGit(dir) {
|
|
126
|
+
try {
|
|
127
|
+
execFileSync("git", ["rev-parse", "--is-inside-work-tree"], { cwd: dir, stdio: "ignore" });
|
|
128
|
+
return;
|
|
129
|
+
} catch {
|
|
130
|
+
}
|
|
131
|
+
try {
|
|
132
|
+
execFileSync("git", ["init"], { cwd: dir, stdio: "ignore" });
|
|
133
|
+
execFileSync("git", ["add", "-A"], { cwd: dir, stdio: "ignore" });
|
|
134
|
+
let identity = [];
|
|
135
|
+
try {
|
|
136
|
+
execFileSync("git", ["config", "user.email"], { cwd: dir, stdio: "ignore" });
|
|
137
|
+
} catch {
|
|
138
|
+
identity = ["-c", "user.name=create", "-c", "user.email=create@users.noreply.github.com"];
|
|
139
|
+
}
|
|
140
|
+
execFileSync("git", [...identity, "commit", "-m", "feat: initialize project", "--no-gpg-sign"], { cwd: dir, stdio: "ignore" });
|
|
141
|
+
} catch {
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
async function runTemplateCLI(template) {
|
|
145
|
+
const argv = process.argv.slice(2);
|
|
146
|
+
if (argv.includes("--help") || argv.includes("-h")) {
|
|
147
|
+
printHelp(template);
|
|
148
|
+
return 0;
|
|
149
|
+
}
|
|
150
|
+
const interactive = Boolean(process.stdout.isTTY) && !argv.includes("--no-interactive");
|
|
151
|
+
const { values, directory: dirArg } = parseArgv(argv, template.options);
|
|
152
|
+
prompts.intro(template.about?.name ?? "create");
|
|
153
|
+
let directory = dirArg;
|
|
154
|
+
if (!directory) {
|
|
155
|
+
if (interactive) {
|
|
156
|
+
const answer = await prompts.text({ message: "Where should we create your project?", placeholder: "my-app", defaultValue: "my-app" });
|
|
157
|
+
if (prompts.isCancel(answer)) {
|
|
158
|
+
prompts.cancel("Cancelled.");
|
|
159
|
+
return 2;
|
|
160
|
+
}
|
|
161
|
+
directory = answer || "my-app";
|
|
162
|
+
} else {
|
|
163
|
+
directory = "my-app";
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
const options = {};
|
|
167
|
+
for (const d of template.options) {
|
|
168
|
+
const def = resolveDefault(d, options, directory);
|
|
169
|
+
if (d.key in values) {
|
|
170
|
+
const value = values[d.key];
|
|
171
|
+
if (d.validate && typeof value === "string") {
|
|
172
|
+
const error = d.validate(value);
|
|
173
|
+
if (error) {
|
|
174
|
+
prompts.cancel(`Invalid --${d.key}: ${error}`);
|
|
175
|
+
return 1;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
options[d.key] = value;
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
if (interactive) {
|
|
182
|
+
const answer = await promptForOption(d, def);
|
|
183
|
+
if (prompts.isCancel(answer)) {
|
|
184
|
+
prompts.cancel("Cancelled.");
|
|
185
|
+
return 2;
|
|
186
|
+
}
|
|
187
|
+
options[d.key] = answer === "" && def !== void 0 ? def : answer;
|
|
188
|
+
} else {
|
|
189
|
+
options[d.key] = def;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
const creation = await template.produce({ options });
|
|
193
|
+
writeTree(creation.files, directory);
|
|
194
|
+
if (creation.scripts?.length) {
|
|
195
|
+
for (const step of [...creation.scripts].sort((a, b) => a.phase - b.phase)) {
|
|
196
|
+
for (const command of step.commands) {
|
|
197
|
+
execFileSync(command, { cwd: directory, stdio: "inherit", shell: true });
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (template.git !== false) initGit(directory);
|
|
202
|
+
if (creation.suggestions?.length) {
|
|
203
|
+
prompts.note(creation.suggestions.join("\n"), "Next steps");
|
|
204
|
+
}
|
|
205
|
+
prompts.outro("Done!");
|
|
206
|
+
return 0;
|
|
207
|
+
}
|
|
46
208
|
|
|
47
209
|
// src/template.ts
|
|
48
|
-
import
|
|
49
|
-
import {
|
|
210
|
+
import path2 from "node:path";
|
|
211
|
+
import { fileURLToPath } from "node:url";
|
|
50
212
|
|
|
51
213
|
// package.json
|
|
52
214
|
var package_default = {
|
|
53
215
|
name: "@pauldvlp/vp-react-ts-nestjs",
|
|
54
|
-
version: "0.
|
|
216
|
+
version: "0.2.0",
|
|
55
217
|
description: "Vite+ monorepo template: a React web app + a NestJS api (conformed to the Vite+ toolchain) sharing Zod contracts.",
|
|
56
218
|
author: "pauldvlp (https://github.com/pauldvlp/vp-templates)",
|
|
57
219
|
license: "MIT",
|
|
@@ -81,8 +243,7 @@ var package_default = {
|
|
|
81
243
|
prepack: "pnpm run build"
|
|
82
244
|
},
|
|
83
245
|
dependencies: {
|
|
84
|
-
|
|
85
|
-
zod: "^3.25.76"
|
|
246
|
+
"@clack/prompts": "^1.6.0"
|
|
86
247
|
},
|
|
87
248
|
devDependencies: {
|
|
88
249
|
"@pauldvlp/template-kit": "workspace:*",
|
|
@@ -96,6 +257,9 @@ var package_default = {
|
|
|
96
257
|
};
|
|
97
258
|
|
|
98
259
|
// src/template.ts
|
|
260
|
+
function validatePort(value) {
|
|
261
|
+
return /^\d+$/.test(value) && Number(value) > 0 && Number(value) < 65536 ? void 0 : "must be a number between 1 and 65535";
|
|
262
|
+
}
|
|
99
263
|
var TEMPLATE_DIR = path2.join(path2.dirname(fileURLToPath(import.meta.url)), "..", "template");
|
|
100
264
|
var MAIN_TS = path2.join("apps", "api", "src", "main.ts");
|
|
101
265
|
var SWAGGER_IMPORT = "import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'";
|
|
@@ -108,34 +272,28 @@ var SERVEWEB_MODULE = [
|
|
|
108
272
|
" exclude: ['/api/*path']",
|
|
109
273
|
" }),"
|
|
110
274
|
].join("\n");
|
|
111
|
-
function applyDocBlock(
|
|
112
|
-
return keep ?
|
|
275
|
+
function applyDocBlock(text2, tag, keep) {
|
|
276
|
+
return keep ? text2.replace(new RegExp(`^<!-- ${tag}:(START|END) -->\\n`, "gm"), "") : text2.replace(new RegExp(`<!-- ${tag}:START -->\\n[\\s\\S]*?<!-- ${tag}:END -->\\n`, "g"), "");
|
|
113
277
|
}
|
|
114
|
-
var template_default =
|
|
278
|
+
var template_default = defineTemplate({
|
|
115
279
|
about: {
|
|
116
280
|
name: package_default.name,
|
|
117
281
|
description: package_default.description
|
|
118
282
|
},
|
|
119
|
-
options:
|
|
120
|
-
name
|
|
121
|
-
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
// so it tracks the project name instead of a fixed value. Falls back to `@app` when no name is set.
|
|
134
|
-
prepare({ options }) {
|
|
135
|
-
return {
|
|
136
|
-
scope: () => options.name ? toScope(options.name) : "@app"
|
|
137
|
-
};
|
|
138
|
-
},
|
|
283
|
+
options: [
|
|
284
|
+
// Default the name to the target directory the user chose, so they don't retype it.
|
|
285
|
+
{ key: "name", type: "string", message: "Root project / package name", default: ({ directory }) => dirName(directory) },
|
|
286
|
+
// Lazily default the package scope to `@<name>` (prefixing `@` unless the name already has one),
|
|
287
|
+
// so it tracks the project name instead of a fixed value. Falls back to `@app` when no name is set.
|
|
288
|
+
{ key: "scope", type: "string", message: "npm scope for workspace packages, e.g. @acme (defaults to @<name>)", default: ({ options }) => options.name ? toScope(options.name) : "@app" },
|
|
289
|
+
// Ports stay strings (validated as integers): they're only ever substituted into config files as text.
|
|
290
|
+
{ key: "apiPort", type: "string", message: "Port the NestJS api listens on", default: "3000", validate: validatePort },
|
|
291
|
+
{ key: "webPort", type: "string", message: "Port the web dev server listens on", default: "5173", validate: validatePort },
|
|
292
|
+
{ key: "swagger", type: "boolean", message: "Expose Swagger UI at /docs on the api", default: false },
|
|
293
|
+
{ key: "serveWeb", type: "boolean", message: "Have the api serve the built web app (single deployable)", default: false },
|
|
294
|
+
{ key: "docker", type: "boolean", message: "Add a multi-stage Dockerfile for the api", default: false },
|
|
295
|
+
{ key: "install", type: "boolean", message: "Install deps after scaffolding", default: true }
|
|
296
|
+
],
|
|
139
297
|
async produce({ options }) {
|
|
140
298
|
const scope = toScope(options.scope || options.name || "app");
|
|
141
299
|
const swaggerSetup = [
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pauldvlp/vp-react-ts-nestjs",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Vite+ monorepo template: a React web app + a NestJS api (conformed to the Vite+ toolchain) sharing Zod contracts.",
|
|
5
5
|
"author": "pauldvlp (https://github.com/pauldvlp/vp-templates)",
|
|
6
6
|
"license": "MIT",
|
|
@@ -24,8 +24,7 @@
|
|
|
24
24
|
"template"
|
|
25
25
|
],
|
|
26
26
|
"dependencies": {
|
|
27
|
-
"
|
|
28
|
-
"zod": "^3.25.76"
|
|
27
|
+
"@clack/prompts": "^1.6.0"
|
|
29
28
|
},
|
|
30
29
|
"devDependencies": {
|
|
31
30
|
"@types/node": "^24",
|
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
import { BadRequestException, type PipeTransform } from '@nestjs/common'
|
|
2
|
-
import type
|
|
2
|
+
import { flattenError, type ZodMiniType } from 'zod/v4-mini'
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Minimal, zero-dependency bridge between Zod and Nest's pipe system. Use it per-route against a
|
|
6
6
|
* schema from `@app/contracts`, e.g. `@Body(new ZodValidationPipe(createItemSchema)) body: CreateItem`.
|
|
7
7
|
*/
|
|
8
8
|
export class ZodValidationPipe<T> implements PipeTransform {
|
|
9
|
-
constructor(private readonly schema:
|
|
9
|
+
constructor(private readonly schema: ZodMiniType<T>) {}
|
|
10
10
|
|
|
11
11
|
transform(value: unknown): T {
|
|
12
12
|
const result = this.schema.safeParse(value)
|
|
13
|
-
if (!result.success) throw new BadRequestException(result.error
|
|
13
|
+
if (!result.success) throw new BadRequestException(flattenError(result.error))
|
|
14
14
|
return result.data
|
|
15
15
|
}
|
|
16
16
|
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import
|
|
1
|
+
import * as z from 'zod/v4-mini'
|
|
2
2
|
|
|
3
3
|
/** Validate `process.env` at boot — the app fails fast with a clear error if something is missing. */
|
|
4
4
|
export const envSchema = z.object({
|
|
5
|
-
NODE_ENV: z.enum(['development', 'production', 'test'])
|
|
6
|
-
PORT: z.coerce.number()
|
|
5
|
+
NODE_ENV: z._default(z.enum(['development', 'production', 'test']), 'development'),
|
|
6
|
+
PORT: z._default(z.coerce.number(), __API_PORT__)
|
|
7
7
|
})
|
|
8
8
|
|
|
9
9
|
export type Env = z.infer<typeof envSchema>
|
|
@@ -1,18 +1,19 @@
|
|
|
1
|
-
import
|
|
1
|
+
import * as z from 'zod/v4-mini'
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Shared API contracts. Both ends import from here so there is a single source of truth:
|
|
5
5
|
* the api validates request bodies against these schemas (via the ZodValidationPipe) and the
|
|
6
|
-
* web app uses the inferred types to type its `fetch` responses.
|
|
6
|
+
* web app uses the inferred types to type its `fetch` responses. Zod Mini keeps the bundle small
|
|
7
|
+
* for the shared package — its functional API (`z.pick`, `.check(...)`) replaces the chained methods.
|
|
7
8
|
*/
|
|
8
9
|
|
|
9
10
|
export const itemSchema = z.object({
|
|
10
11
|
id: z.string(),
|
|
11
|
-
name: z.string().
|
|
12
|
+
name: z.string().check(z.minLength(1)),
|
|
12
13
|
createdAt: z.string()
|
|
13
14
|
})
|
|
14
15
|
|
|
15
|
-
export const createItemSchema =
|
|
16
|
+
export const createItemSchema = z.pick(itemSchema, { name: true })
|
|
16
17
|
|
|
17
18
|
export type Item = z.infer<typeof itemSchema>
|
|
18
19
|
export type CreateItem = z.infer<typeof createItemSchema>
|