create-nexst-monorepo 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.
Files changed (41) hide show
  1. package/dist/credentials.js +58 -0
  2. package/dist/features.js +145 -0
  3. package/dist/generate.js +167 -0
  4. package/dist/index.js +241 -1699
  5. package/dist/markers.js +112 -0
  6. package/package.json +20 -11
  7. package/template/.dockerignore +2 -2
  8. package/template/.env.example +3 -0
  9. package/template/.github/workflows/ci.yaml +53 -41
  10. package/template/.github/workflows/{publish.yaml → deploy.yaml} +34 -16
  11. package/template/AGENTS.md +16 -14
  12. package/template/Dockerfile.base +4 -5
  13. package/template/README.md +16 -13
  14. package/template/apps/api/.env.example +1 -1
  15. package/template/apps/api/AGENTS.md +6 -6
  16. package/template/apps/api/Dockerfile +28 -22
  17. package/template/apps/api/package.json +9 -7
  18. package/template/apps/api/prisma.config.ts +3 -0
  19. package/template/apps/api/scripts/auth.cli.ts +1 -1
  20. package/template/apps/api/src/config/graphql-depth-limit.ts +1 -5
  21. package/template/apps/api/vitest.config.e2e.ts +1 -1
  22. package/template/apps/web/.env.example +1 -1
  23. package/template/apps/web/.storybook/preview.ts +1 -1
  24. package/template/apps/web/AGENTS.md +3 -3
  25. package/template/apps/web/Dockerfile +14 -12
  26. package/template/apps/web/_gitignore +1 -1
  27. package/template/apps/web/playwright.config.ts +1 -1
  28. package/template/apps/web/src/lib/auth-server.ts +1 -1
  29. package/template/apps/web/src/lib/codegen.ts +1 -1
  30. package/template/apps/worker/.env.example +1 -1
  31. package/template/apps/worker/AGENTS.md +2 -2
  32. package/template/apps/worker/Dockerfile +14 -11
  33. package/template/apps/worker/kustomize/base/deployment.yaml +1 -1
  34. package/template/apps/worker/package.json +3 -1
  35. package/template/compose.yaml +1 -1
  36. package/template/docker-bake.hcl +7 -7
  37. package/template/package.json +4 -14
  38. package/template/pnpm-lock.yaml +14634 -0
  39. package/template/pnpm-workspace.yaml +28 -0
  40. package/template/bun.lock +0 -3053
  41. package/template/bunfig.toml +0 -2
@@ -0,0 +1,58 @@
1
+ // Safe to write unquoted into .env files (no spaces, quotes, `$` or `#`).
2
+ const TOKEN = { hint: "Letters, digits, '.', '-' and '_' only", pattern: /^[\w.-]+$/ };
3
+ /** In prompt order. */
4
+ export const CREDENTIALS = [
5
+ {
6
+ flag: "db-password",
7
+ // Written single-quoted into .env, URL-encoded into DATABASE_URL.
8
+ hint: "No spaces or single quotes",
9
+ label: "Postgres password (local dev database)",
10
+ masked: true,
11
+ name: "dbPassword",
12
+ pattern: /^[^\s']+$/,
13
+ },
14
+ {
15
+ feature: "auth",
16
+ flag: "google-client-id",
17
+ hint: "Expected <id>.apps.googleusercontent.com",
18
+ label: "Google OAuth client ID",
19
+ name: "googleClientId",
20
+ pattern: /^[\w.-]+\.apps\.googleusercontent\.com$/,
21
+ },
22
+ {
23
+ feature: "auth",
24
+ flag: "google-client-secret",
25
+ label: "Google OAuth client secret",
26
+ masked: true,
27
+ name: "googleClientSecret",
28
+ ...TOKEN,
29
+ },
30
+ {
31
+ feature: "r2",
32
+ flag: "r2-account-id",
33
+ hint: "Expected the 32-character hex Cloudflare account ID",
34
+ label: "Cloudflare account ID",
35
+ name: "r2AccountId",
36
+ pattern: /^[\da-f]{32}$/i,
37
+ },
38
+ {
39
+ feature: "r2",
40
+ flag: "r2-access-key-id",
41
+ label: "R2 access key ID",
42
+ name: "r2AccessKeyId",
43
+ ...TOKEN,
44
+ },
45
+ {
46
+ feature: "r2",
47
+ flag: "r2-secret-access-key",
48
+ label: "R2 secret access key",
49
+ masked: true,
50
+ name: "r2SecretAccessKey",
51
+ ...TOKEN,
52
+ },
53
+ ];
54
+ export function validateCredential(spec, value) {
55
+ if (!value)
56
+ return "Required";
57
+ return spec.pattern.test(value) ? undefined : spec.hint;
58
+ }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * What each optional feature owns in the template. Inline code is handled by
3
+ * `@if` markers (see markers.ts); this manifest covers what markers cannot:
4
+ * whole files/directories, package.json entries and JSON message keys.
5
+ *
6
+ * Keep it in sync when template code for a feature is added or moved —
7
+ * `pnpm run test:matrix` in cli/ generates every combination and typechecks it.
8
+ */
9
+ export const FEATURE_NAMES = ["auth", "r2", "ui", "worker"];
10
+ const WEB_MESSAGES = ["apps/web/messages/en.json", "apps/web/messages/de.json"];
11
+ export const FEATURES = {
12
+ auth: {
13
+ description: "Better Auth (email + password, passkeys, Google, verification & reset mails) in API and web",
14
+ jsonKeys: Object.fromEntries(WEB_MESSAGES.map((file) => [
15
+ file,
16
+ [
17
+ "Auth",
18
+ "Dashboard",
19
+ "Header.dashboard",
20
+ "Header.signIn",
21
+ "Header.signOut",
22
+ "Home.cta",
23
+ "ApiErrors.EMAIL_NOT_VERIFIED",
24
+ "ApiErrors.INSUFFICIENT_ROLE",
25
+ ],
26
+ ])),
27
+ label: "Auth",
28
+ packages: {
29
+ "apps/api/package.json": {
30
+ dependencies: [
31
+ "@better-auth/passkey",
32
+ "@thallesp/nestjs-better-auth",
33
+ "better-auth",
34
+ "nodemailer",
35
+ "resend",
36
+ ],
37
+ devDependencies: ["@types/nodemailer"],
38
+ scripts: ["auth:generate"],
39
+ },
40
+ "apps/web/package.json": {
41
+ dependencies: [
42
+ "@better-auth/passkey",
43
+ "better-auth",
44
+ "@hookform/resolvers",
45
+ "react-hook-form",
46
+ ],
47
+ },
48
+ },
49
+ paths: [
50
+ "apps/api/scripts/auth.cli.ts",
51
+ "apps/api/src/auth",
52
+ "apps/api/src/bootstrap/register-graphql-enums.ts",
53
+ "apps/api/src/mail",
54
+ "apps/api/src/user",
55
+ "apps/api/tests/auth.e2e-spec.ts",
56
+ "apps/web/src/app/[locale]/(auth)",
57
+ "apps/web/src/app/[locale]/dashboard",
58
+ "apps/web/src/components/icons.tsx",
59
+ "apps/web/src/config/routes.ts",
60
+ "apps/web/src/features/auth",
61
+ "apps/web/src/lib/auth-client.ts",
62
+ "apps/web/src/lib/auth-server.ts",
63
+ "apps/web/src/lib/auth.ts",
64
+ "apps/web/tests/auth.spec.ts",
65
+ ],
66
+ },
67
+ r2: {
68
+ description: "Cloudflare R2 object storage service (uploads, presigned URLs)",
69
+ label: "Cloudflare R2",
70
+ packages: {
71
+ "apps/api/package.json": {
72
+ dependencies: ["@aws-sdk/client-s3", "@aws-sdk/s3-request-presigner"],
73
+ devDependencies: ["aws-sdk-client-mock"],
74
+ },
75
+ },
76
+ paths: ["apps/api/src/r2"],
77
+ },
78
+ ui: {
79
+ description: "Extra UI components (dialog, drawer, select, switch, tabs, tooltip, …) with stories",
80
+ jsonKeys: Object.fromEntries(WEB_MESSAGES.map((file) => [
81
+ file,
82
+ [
83
+ "Common.cancel",
84
+ "Common.a11y.clearSearch",
85
+ "Common.a11y.close",
86
+ "Common.a11y.scrollDown",
87
+ "Common.a11y.scrollUp",
88
+ ],
89
+ ])),
90
+ label: "UI components",
91
+ packages: {
92
+ "apps/web/package.json": {
93
+ dependencies: [
94
+ "@radix-ui/react-checkbox",
95
+ "@radix-ui/react-dialog",
96
+ "@radix-ui/react-dropdown-menu",
97
+ "@radix-ui/react-popover",
98
+ "@radix-ui/react-select",
99
+ "@radix-ui/react-tabs",
100
+ "@radix-ui/react-tooltip",
101
+ "vaul",
102
+ ],
103
+ },
104
+ },
105
+ paths: [
106
+ ...[
107
+ "avatar",
108
+ "checkbox",
109
+ "dialog",
110
+ "dialog-buttons.stories.tsx",
111
+ "dialog-buttons.tsx",
112
+ "drawer",
113
+ "dropdown-menu",
114
+ "empty-state.stories.tsx",
115
+ "empty-state.tsx",
116
+ "field-group.stories.tsx",
117
+ "field-group.tsx",
118
+ "field-label.stories.tsx",
119
+ "field-label.tsx",
120
+ "input/input-otp.stories.tsx",
121
+ "input/input-otp.tsx",
122
+ "popover",
123
+ "responsive-dialog",
124
+ "searchbar",
125
+ "select",
126
+ "skeleton",
127
+ "switch",
128
+ "tabs",
129
+ "textarea",
130
+ "tooltip",
131
+ ].map((component) => `apps/web/src/components/${component}`),
132
+ "apps/web/src/lib/hooks",
133
+ ],
134
+ },
135
+ worker: {
136
+ description: "BullMQ worker app + Redis, with shared queue contracts",
137
+ label: "Worker",
138
+ packages: {
139
+ "apps/api/package.json": {
140
+ dependencies: ["@nexst/queues", "@nestjs/bullmq", "bullmq", "ioredis"],
141
+ },
142
+ },
143
+ paths: ["apps/worker", "packages/queues", "apps/api/src/queue"],
144
+ },
145
+ };
@@ -0,0 +1,167 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { existsSync } from "node:fs";
3
+ import { cp, mkdir, readdir, readFile, rename, rm, writeFile } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { FEATURE_NAMES, FEATURES } from "./features.js";
6
+ import { applyMarkers, hasMarkers } from "./markers.js";
7
+ /** Paths never copied from the template (build output, caches, local state). */
8
+ const SKIP = new Set([
9
+ ".env",
10
+ ".env.development",
11
+ ".next",
12
+ ".turbo",
13
+ "coverage",
14
+ "dist",
15
+ "node_modules",
16
+ "playwright-report",
17
+ "storybook-static",
18
+ "test-results",
19
+ ]);
20
+ const BINARY = /\.(?:png|jpe?g|gif|webp|ico|woff2?|ttf|pdf|zip)$/i;
21
+ export const NAME_PATTERN = /^[a-z][a-z0-9-]*[a-z0-9]$/;
22
+ export function toTitle(name) {
23
+ return name
24
+ .split("-")
25
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
26
+ .join(" ");
27
+ }
28
+ /**
29
+ * Replaces the template's placeholder tokens: `nexst` (slug), `nexst_` (SQL-safe
30
+ * slug), `Nexst` (display name), `your-org`, `example.com`.
31
+ */
32
+ export function replaceTokens(text, options) {
33
+ const snake = options.name.replaceAll("-", "_");
34
+ return text
35
+ .replaceAll("create-nexst-monorepo", "\u0000CLI\u0000")
36
+ .replaceAll("nexst_", `${snake}_`)
37
+ .replaceAll("Nexst", toTitle(options.name))
38
+ .replaceAll("nexst", options.name)
39
+ .replaceAll("your-org", options.owner)
40
+ .replaceAll("example.com", options.domain)
41
+ .replaceAll("\u0000CLI\u0000", "create-nexst-monorepo");
42
+ }
43
+ export async function generate(options) {
44
+ if (!NAME_PATTERN.test(options.name)) {
45
+ throw new Error(`Invalid project name "${options.name}" (lowercase letters, digits, dashes)`);
46
+ }
47
+ if (existsSync(options.targetDir) && (await readdir(options.targetDir)).length > 0) {
48
+ throw new Error(`${options.targetDir} already exists and is not empty`);
49
+ }
50
+ await mkdir(options.targetDir, { recursive: true });
51
+ await cp(options.templateDir, options.targetDir, {
52
+ filter: (source) => !SKIP.has(path.basename(source)) && !source.endsWith(".tsbuildinfo"),
53
+ recursive: true,
54
+ });
55
+ // npm strips .gitignore from published packages, so the packed template ships _gitignore.
56
+ for (const file of await walk(options.targetDir)) {
57
+ if (path.basename(file) === "_gitignore") {
58
+ await rename(file, path.join(path.dirname(file), ".gitignore"));
59
+ }
60
+ }
61
+ const disabled = FEATURE_NAMES.filter((feature) => !options.features[feature]);
62
+ for (const feature of disabled) {
63
+ await removeFeature(options.targetDir, feature);
64
+ }
65
+ const r2AccountId = options.credentials?.r2AccountId;
66
+ const allOn = Object.fromEntries(FEATURE_NAMES.map((feature) => [feature, true]));
67
+ for (const file of await walk(options.targetDir)) {
68
+ if (BINARY.test(file) || path.basename(file) === "pnpm-lock.yaml") {
69
+ if (path.basename(file) === "pnpm-lock.yaml") {
70
+ await writeFile(file, replaceTokens(await readFile(file, "utf8"), options));
71
+ }
72
+ continue;
73
+ }
74
+ const original = await readFile(file, "utf8");
75
+ const resolved = hasMarkers(original)
76
+ ? applyMarkers(original, options.features, allOn, path.relative(options.targetDir, file))
77
+ : original;
78
+ let replaced = replaceTokens(resolved, options);
79
+ if (r2AccountId)
80
+ replaced = replaced.replaceAll("<account-id>", r2AccountId);
81
+ if (replaced !== original)
82
+ await writeFile(file, replaced);
83
+ }
84
+ await writeEnvFiles(options);
85
+ }
86
+ async function removeFeature(root, feature) {
87
+ const manifest = FEATURES[feature];
88
+ for (const target of manifest.paths) {
89
+ await rm(path.join(root, target), { force: true, recursive: true });
90
+ }
91
+ for (const [file, edits] of Object.entries(manifest.packages ?? {})) {
92
+ const full = path.join(root, file);
93
+ if (!existsSync(full))
94
+ continue;
95
+ const pkg = JSON.parse(await readFile(full, "utf8"));
96
+ for (const [section, names] of Object.entries(edits)) {
97
+ for (const name of names)
98
+ delete pkg[section]?.[name];
99
+ }
100
+ await writeFile(full, `${JSON.stringify(pkg, null, 2)}\n`);
101
+ }
102
+ for (const [file, keys] of Object.entries(manifest.jsonKeys ?? {})) {
103
+ const full = path.join(root, file);
104
+ if (!existsSync(full))
105
+ continue;
106
+ const json = JSON.parse(await readFile(full, "utf8"));
107
+ for (const key of keys)
108
+ deleteKey(json, key.split("."));
109
+ await writeFile(full, `${JSON.stringify(json, null, 2)}\n`);
110
+ }
111
+ }
112
+ function deleteKey(object, [head, ...rest]) {
113
+ if (rest.length === 0) {
114
+ delete object[head];
115
+ return;
116
+ }
117
+ const child = object[head];
118
+ if (child && typeof child === "object")
119
+ deleteKey(child, rest);
120
+ }
121
+ /**
122
+ * Turns every `.env.example` into the git-ignored file the dev scripts (root: docker
123
+ * compose) load, with a fresh auth secret and the credentials entered at init.
124
+ */
125
+ async function writeEnvFiles(options) {
126
+ const targets = {
127
+ ".": ".env",
128
+ "apps/api": ".env.development",
129
+ "apps/web": ".env",
130
+ "apps/worker": ".env",
131
+ };
132
+ const credentials = options.credentials ?? {};
133
+ const values = {
134
+ BETTER_AUTH_SECRET: randomBytes(32).toString("base64"),
135
+ GOOGLE_CLIENT_ID: credentials.googleClientId,
136
+ GOOGLE_CLIENT_SECRET: credentials.googleClientSecret,
137
+ NEXT_PUBLIC_GOOGLE_CLIENT_ID: credentials.googleClientId,
138
+ // Single-quoted so docker compose takes it literally (no `$` interpolation).
139
+ POSTGRES_PASSWORD: credentials.dbPassword && `'${credentials.dbPassword}'`,
140
+ R2_ACCESS_KEY_ID: credentials.r2AccessKeyId,
141
+ R2_SECRET_ACCESS_KEY: credentials.r2SecretAccessKey,
142
+ };
143
+ for (const [dir, envFile] of Object.entries(targets)) {
144
+ const example = path.join(options.targetDir, dir, ".env.example");
145
+ if (!existsSync(example))
146
+ continue;
147
+ let content = await readFile(example, "utf8");
148
+ for (const [key, value] of Object.entries(values)) {
149
+ // Also uncomments optional `# KEY=` placeholders.
150
+ if (value)
151
+ content = content.replace(new RegExp(`^(?:# )?${key}=.*$`, "m"), () => `${key}=${value}`);
152
+ }
153
+ if (credentials.dbPassword) {
154
+ const password = encodeURIComponent(credentials.dbPassword);
155
+ content = content.replace(/^(DATABASE_URL=\w+:\/\/[^:/@]+:)[^@]*@/m, (_, prefix) => `${prefix}${password}@`);
156
+ }
157
+ await writeFile(path.join(options.targetDir, dir, envFile), content);
158
+ }
159
+ }
160
+ async function walk(dir) {
161
+ const entries = await readdir(dir, { withFileTypes: true });
162
+ const files = await Promise.all(entries.map((entry) => {
163
+ const full = path.join(dir, entry.name);
164
+ return entry.isDirectory() ? walk(full) : Promise.resolve([full]);
165
+ }));
166
+ return files.flat();
167
+ }