create-pyro 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 (91) hide show
  1. package/README.md +32 -11
  2. package/dist/index.js +150 -30
  3. package/dist/scaffold.js +106 -0
  4. package/package.json +7 -7
  5. package/template/.dockerignore +30 -0
  6. package/template/.env.example +23 -0
  7. package/template/.gitattributes +2 -0
  8. package/template/.github/workflows/ci.yml +43 -0
  9. package/template/.vscode/extensions.json +3 -0
  10. package/template/.vscode/settings.json +8 -0
  11. package/template/README.md +57 -4
  12. package/template/apps/api/Dockerfile +46 -0
  13. package/template/apps/api/package.json +43 -0
  14. package/template/apps/api/src/app.test.ts +44 -0
  15. package/template/apps/api/src/app.ts +54 -0
  16. package/template/apps/api/src/index.ts +13 -0
  17. package/template/apps/api/src/lib/auth.ts +46 -0
  18. package/template/apps/api/src/lib/response.ts +28 -0
  19. package/template/apps/api/src/lib/session.ts +22 -0
  20. package/template/apps/api/src/middleware/auth.ts +34 -0
  21. package/template/apps/api/src/routes/auth.ts +9 -0
  22. package/template/apps/api/src/routes/index.ts +8 -0
  23. package/template/apps/api/src/routes/me.test.ts +45 -0
  24. package/template/apps/api/src/routes/me.ts +29 -0
  25. package/template/apps/api/src/test/database-url.ts +23 -0
  26. package/template/apps/api/src/test/global-setup.ts +36 -0
  27. package/template/apps/api/src/test/setup.ts +8 -0
  28. package/template/apps/api/src/types/context.ts +8 -0
  29. package/template/apps/api/tsconfig.build.json +8 -0
  30. package/template/apps/api/tsconfig.json +9 -0
  31. package/template/apps/api/tsdown.config.ts +13 -0
  32. package/template/apps/api/vitest.config.ts +11 -0
  33. package/template/apps/web/Dockerfile +49 -0
  34. package/template/apps/web/app/app.css +29 -0
  35. package/template/apps/web/app/lib/api.server.ts +13 -0
  36. package/template/apps/web/app/lib/api.test.ts +28 -0
  37. package/template/apps/web/app/lib/api.ts +47 -0
  38. package/template/apps/web/app/lib/auth-client.ts +13 -0
  39. package/template/apps/web/app/lib/env.server.ts +11 -0
  40. package/template/apps/web/app/lib/public-env.ts +17 -0
  41. package/template/apps/web/app/lib/session.server.ts +31 -0
  42. package/template/apps/web/app/lib/utils.ts +6 -0
  43. package/template/apps/web/app/root.tsx +86 -0
  44. package/template/apps/web/app/routes/dashboard.tsx +81 -0
  45. package/template/apps/web/app/routes/home.tsx +161 -0
  46. package/template/apps/web/app/routes.ts +6 -0
  47. package/template/apps/web/package.json +41 -0
  48. package/template/apps/web/public/favicon.svg +5 -0
  49. package/template/apps/web/react-router.config.ts +6 -0
  50. package/template/apps/web/tsconfig.json +23 -0
  51. package/template/apps/web/vite.config.ts +19 -0
  52. package/template/apps/web/vitest.config.ts +13 -0
  53. package/template/biome.json +53 -0
  54. package/template/compose.dev.yml +26 -0
  55. package/template/compose.yml +75 -0
  56. package/template/gitignore +32 -1
  57. package/template/infra/nginx/default.conf +33 -0
  58. package/template/package.json +23 -1
  59. package/template/packages/config/package.json +31 -0
  60. package/template/packages/config/src/index.test.ts +100 -0
  61. package/template/packages/config/src/index.ts +100 -0
  62. package/template/packages/config/tsconfig.build.json +8 -0
  63. package/template/packages/config/tsconfig.json +9 -0
  64. package/template/packages/config/tsdown.config.ts +12 -0
  65. package/template/packages/db/Dockerfile +22 -0
  66. package/template/packages/db/drizzle/0000_init.sql +53 -0
  67. package/template/packages/db/drizzle/meta/0000_snapshot.json +376 -0
  68. package/template/packages/db/drizzle/meta/_journal.json +13 -0
  69. package/template/packages/db/drizzle.config.ts +12 -0
  70. package/template/packages/db/package.json +38 -0
  71. package/template/packages/db/src/client.ts +12 -0
  72. package/template/packages/db/src/index.ts +2 -0
  73. package/template/packages/db/src/schema/auth.ts +96 -0
  74. package/template/packages/db/src/schema/index.ts +1 -0
  75. package/template/packages/db/tsconfig.build.json +7 -0
  76. package/template/packages/db/tsconfig.json +9 -0
  77. package/template/packages/db/tsdown.config.ts +12 -0
  78. package/template/packages/shared/package.json +32 -0
  79. package/template/packages/shared/src/index.ts +2 -0
  80. package/template/packages/shared/src/schemas/auth.test.ts +32 -0
  81. package/template/packages/shared/src/schemas/auth.ts +22 -0
  82. package/template/packages/shared/src/schemas/index.ts +1 -0
  83. package/template/packages/shared/src/types/api.ts +15 -0
  84. package/template/packages/shared/src/types/index.ts +2 -0
  85. package/template/packages/shared/src/types/user.ts +10 -0
  86. package/template/packages/shared/tsconfig.build.json +8 -0
  87. package/template/packages/shared/tsconfig.json +9 -0
  88. package/template/packages/shared/tsdown.config.ts +12 -0
  89. package/template/pnpm-workspace.yaml +25 -0
  90. package/template/tsconfig.json +19 -0
  91. package/template/turbo.json +27 -0
package/README.md CHANGED
@@ -1,35 +1,54 @@
1
1
  # Create Pyro
2
2
 
3
- Scaffold a new Pyro monorepo project.
4
-
5
- > **Note:** The full monorepo template is still under development. See "What you get" below for current state.
3
+ Scaffold a new Pyro monorepo: a Hono api and a React Router web app with shared, typed packages, Postgres via Drizzle and auth via Better Auth.
6
4
 
7
5
  ## Usage
8
6
 
9
7
  ```bash
10
- npx create-pyro@latest
11
- # or
12
8
  pnpm create pyro
9
+ # or
10
+ npx create-pyro@latest
13
11
 
14
12
  # With a project name
15
13
  pnpm create pyro my-app
16
14
 
17
- # Scaffold into the current directory
15
+ # Scaffold into the current directory (must be empty, or hold only .git)
18
16
  pnpm create pyro .
19
17
  ```
20
18
 
19
+ The CLI asks for anything you don't pass as a flag:
20
+
21
+ | Flag | Default | What it does |
22
+ | ---------------------------- | ------------ | ----------------------------------------------- |
23
+ | `--scope <name>` | project name | Scope for workspace packages (`@name/api`, ...) |
24
+ | `--git` / `--no-git` | yes | `git init` with an initial commit |
25
+ | `--install` / `--no-install` | yes | Run `pnpm install` |
26
+ | `-y`, `--yes` | | Accept defaults for anything not set by a flag |
27
+
28
+ It also writes a `.env` with freshly generated secrets, so the project runs without any manual setup.
29
+
21
30
  ## What you get
22
31
 
23
- Currently scaffolds a minimal starter project (`package.json`, `.gitignore`). Planned for the full template:
32
+ ```
33
+ apps/
34
+ api/ Hono + Better Auth (email and password), Node server
35
+ web/ React Router 8 with SSR, Tailwind CSS 4, typed api client
36
+ packages/
37
+ config/ Zod-validated environment, split into lazily-checked scopes
38
+ db/ Drizzle schema, client and migrations for Postgres
39
+ shared/ Types and Zod schemas shared by both apps
40
+ infra/nginx/ Reverse proxy for the production stack
41
+ ```
42
+
43
+ Plus pnpm workspaces with a dependency catalog, Turborepo, Biome, tsdown, Vitest, dotenv-run, Docker Compose files for development and production, and a GitHub Actions workflow.
24
44
 
25
- - Turborepo
26
- - Biome (lint + format)
27
- - pnpm workspaces
45
+ The web app has a homepage with sign-in and sign-up, and a dashboard that shows the signed-in user and redirects to the homepage otherwise.
28
46
 
29
47
  ## Requirements
30
48
 
31
49
  - Node.js 24+
32
- - pnpm 11+
50
+ - pnpm 12+
51
+ - Docker, for Postgres in development and for the production stack
33
52
 
34
53
  ## Development
35
54
 
@@ -41,6 +60,8 @@ pnpm build
41
60
  pnpm test
42
61
  ```
43
62
 
63
+ The template lives in `template/`. It uses `@repo/` as its package scope and `my-pyro-app` as its name; the CLI replaces both in every file. CI scaffolds a project from it and runs that project's lint, typecheck, tests and build, then brings up the production compose stack.
64
+
44
65
  ## Issues
45
66
 
46
67
  Found a bug or have a feature request? [Open an issue](https://github.com/poran-dip/create-pyro/issues).
package/dist/index.js CHANGED
@@ -1,39 +1,159 @@
1
1
  #!/usr/bin/env node
2
- import fs from "node:fs";
2
+ import { spawnSync } from "node:child_process";
3
3
  import path from "node:path";
4
+ import { parseArgs } from "node:util";
4
5
  import prompts from "prompts";
5
- const __dirname = import.meta.dirname;
6
- const arg = process.argv[2];
7
- const { targetDir } = arg
8
- ? { targetDir: arg }
9
- : await prompts({
6
+ import { isEmptyDir, normalizeScope, scaffold, TEMPLATE_NAME, toSlug, validateScope, } from "./scaffold.js";
7
+ const HELP = `
8
+ Usage: create-pyro [directory] [options]
9
+
10
+ Options:
11
+ --scope <name> Package scope for workspace packages (default: project name)
12
+ --git, --no-git Initialize a git repository (default: ask, then yes)
13
+ --install, --no-install
14
+ Install dependencies with pnpm (default: ask, then yes)
15
+ -y, --yes Accept the default for every question not set by a flag
16
+ -h, --help Show this help
17
+ `;
18
+ const { values: flags, positionals } = parseArgs({
19
+ allowPositionals: true,
20
+ allowNegative: true,
21
+ options: {
22
+ scope: { type: "string" },
23
+ git: { type: "boolean" },
24
+ install: { type: "boolean" },
25
+ yes: { type: "boolean", short: "y" },
26
+ help: { type: "boolean", short: "h" },
27
+ },
28
+ });
29
+ if (flags.help) {
30
+ console.log(HELP);
31
+ process.exit(0);
32
+ }
33
+ const onCancel = () => process.exit(0);
34
+ const useDefaults = flags.yes ?? false;
35
+ const fail = (message) => {
36
+ console.error(`\n❌ ${message}\n`);
37
+ process.exit(1);
38
+ };
39
+ // Project directory.
40
+ let targetDir = positionals[0];
41
+ if (!targetDir && useDefaults)
42
+ targetDir = TEMPLATE_NAME;
43
+ if (!targetDir) {
44
+ ({ targetDir } = await prompts({
10
45
  type: "text",
11
46
  name: "targetDir",
12
47
  message: "Project name:",
13
- initial: "my-pyro-app",
14
- validate: (value) => value.trim() ? true : "Project name cannot be empty",
15
- }, {
16
- onCancel: () => {
17
- process.exit(0);
18
- },
19
- });
48
+ initial: TEMPLATE_NAME,
49
+ validate: (value) => toSlug(value === "." ? path.basename(process.cwd()) : value)
50
+ ? true
51
+ : "Project name cannot be empty",
52
+ }, { onCancel }));
53
+ }
20
54
  if (!targetDir)
21
55
  process.exit(0);
22
- const dest = targetDir === "." ? process.cwd() : path.resolve(process.cwd(), targetDir);
23
- const projectName = path.basename(dest);
24
- if (fs.existsSync(dest) &&
25
- fs.readdirSync(dest).length > 0 &&
26
- targetDir !== ".") {
27
- console.error(`\n❌ Directory "${projectName}" already exists.\n`);
28
- process.exit(1);
56
+ const dest = path.resolve(process.cwd(), targetDir);
57
+ const dirName = path.basename(dest);
58
+ const name = toSlug(dirName);
59
+ if (!name)
60
+ fail(`Can't derive a package name from "${dirName}".`);
61
+ if (!isEmptyDir(dest))
62
+ fail(`Directory "${dirName}" already exists and is not empty.`);
63
+ // Remaining options: flags win, then --yes defaults, then prompts.
64
+ const flagScope = flags.scope !== undefined ? normalizeScope(flags.scope) : undefined;
65
+ if (flagScope !== undefined) {
66
+ const valid = validateScope(flagScope);
67
+ if (valid !== true)
68
+ fail(`Invalid --scope "${flags.scope}": ${valid}`);
69
+ }
70
+ const answers = await prompts([
71
+ {
72
+ type: flagScope !== undefined || useDefaults ? null : "text",
73
+ name: "scope",
74
+ message: "Package scope (workspace packages are named @scope/...):",
75
+ initial: name,
76
+ format: (value) => normalizeScope(value),
77
+ validate: (value) => validateScope(normalizeScope(value)),
78
+ },
79
+ {
80
+ type: flags.git !== undefined || useDefaults ? null : "confirm",
81
+ name: "git",
82
+ message: "Initialize a git repository?",
83
+ initial: true,
84
+ },
85
+ {
86
+ type: flags.install !== undefined || useDefaults ? null : "confirm",
87
+ name: "install",
88
+ message: "Install dependencies with pnpm?",
89
+ initial: true,
90
+ },
91
+ ], { onCancel });
92
+ const scope = flagScope ?? answers.scope ?? name;
93
+ const initGit = flags.git ?? answers.git ?? true;
94
+ const install = flags.install ?? answers.install ?? true;
95
+ // Scaffold.
96
+ scaffold({
97
+ templateDir: path.join(import.meta.dirname, "../template"),
98
+ dest,
99
+ name,
100
+ scope,
101
+ });
102
+ console.log(`\n📦 Scaffolded ${name} with packages scoped @${scope}/*`);
103
+ // On Windows, pnpm is a .cmd shim that only runs through a shell, but a shell
104
+ // joins args unquoted, splitting any with spaces. So only use one for pnpm,
105
+ // and never pass it args with spaces.
106
+ const run = (command, args, quiet = false) => spawnSync(command, args, {
107
+ cwd: dest,
108
+ stdio: quiet ? "ignore" : "inherit",
109
+ shell: process.platform === "win32" && command === "pnpm",
110
+ }).status === 0;
111
+ let installed = false;
112
+ if (install) {
113
+ if (!run("pnpm", ["--version"], true)) {
114
+ console.warn("\n⚠️ pnpm isn't on your PATH, so dependencies weren't installed.\n" +
115
+ " Enable it with `corepack enable`, then run `pnpm install`.");
116
+ }
117
+ else {
118
+ console.log("\n📥 Installing dependencies...\n");
119
+ installed = run("pnpm", ["install", "--no-frozen-lockfile"]);
120
+ if (!installed)
121
+ console.warn("\n⚠️ `pnpm install` failed. Fix the error above and rerun it.");
122
+ }
123
+ }
124
+ if (initGit) {
125
+ if (!run("git", ["--version"], true)) {
126
+ console.warn("\n⚠️ git isn't on your PATH, so no repository was created.");
127
+ }
128
+ else if (run("git", ["rev-parse", "--is-inside-work-tree"], true)) {
129
+ console.log("\nℹ️ Already inside a git repository, skipped git init.");
130
+ }
131
+ else {
132
+ run("git", ["init", "--quiet", "--initial-branch=main"], true) ||
133
+ run("git", ["init", "--quiet"], true);
134
+ run("git", ["add", "-A"], true);
135
+ const committed = run("git", [
136
+ "commit",
137
+ "--quiet",
138
+ "--no-verify",
139
+ "-m",
140
+ "Initial commit from create-pyro",
141
+ ], true);
142
+ console.log(committed
143
+ ? "\n🌱 Initialized a git repository with an initial commit."
144
+ : "\n🌱 Initialized a git repository (files staged; commit when ready).");
145
+ }
29
146
  }
30
- fs.cpSync(path.join(__dirname, "../template"), dest, { recursive: true });
31
- fs.renameSync(`${dest}/gitignore`, `${dest}/.gitignore`);
32
- const pkgPath = path.join(dest, "package.json");
33
- const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
34
- pkg.name = projectName;
35
- fs.writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
36
- console.log(`\n✅ Created ${projectName}! Now run:\n`);
37
- console.log(` cd ${projectName}`);
38
- console.log(` pnpm install`);
39
- console.log(` pnpm dev\n`);
147
+ // Next steps.
148
+ const steps = [
149
+ path.relative(process.cwd(), dest) &&
150
+ `cd ${path.relative(process.cwd(), dest)}`,
151
+ !installed && "pnpm install",
152
+ "docker compose -f compose.dev.yml up -d",
153
+ "pnpm db:migrate",
154
+ "pnpm dev",
155
+ ].filter(Boolean);
156
+ console.log(`\n✅ Created ${name}! Now run:\n`);
157
+ for (const step of steps)
158
+ console.log(` ${step}`);
159
+ console.log("\nA .env with fresh secrets is already in place.\n");
@@ -0,0 +1,106 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ /** The placeholders the template uses, rewritten in every scaffolded file. */
5
+ export const TEMPLATE_SCOPE = "repo";
6
+ export const TEMPLATE_NAME = "my-pyro-app";
7
+ /** npm strips these from published packages, so the template stores them renamed. */
8
+ const RENAMED_FILES = {
9
+ gitignore: ".gitignore",
10
+ };
11
+ const VALID_SCOPE = /^[a-z0-9][a-z0-9._-]*$/;
12
+ /**
13
+ * Turns a directory name into something valid as an npm package name, a
14
+ * docker compose project name and a Postgres-friendly identifier.
15
+ */
16
+ export function toSlug(input) {
17
+ return input
18
+ .trim()
19
+ .toLowerCase()
20
+ .replace(/[^a-z0-9_-]+/g, "-")
21
+ .replace(/^[-_]+|[-_]+$/g, "");
22
+ }
23
+ /** Normalizes a scope as typed (`@acme`, `Acme`) to its bare form (`acme`). */
24
+ export function normalizeScope(input) {
25
+ return input.trim().replace(/^@/, "").replace(/\/$/, "").toLowerCase();
26
+ }
27
+ export function validateScope(scope) {
28
+ if (!scope)
29
+ return "Scope cannot be empty";
30
+ if (scope.length > 214)
31
+ return "Scope is too long";
32
+ if (!VALID_SCOPE.test(scope))
33
+ return "Use lowercase letters, digits, '-', '.' or '_', starting with a letter or digit";
34
+ return true;
35
+ }
36
+ /** True when `dir` is missing or has nothing in it but a `.git` folder. */
37
+ export function isEmptyDir(dir) {
38
+ if (!fs.existsSync(dir))
39
+ return true;
40
+ return fs.readdirSync(dir).every((entry) => entry === ".git");
41
+ }
42
+ export function scaffold({ templateDir, dest, name, scope }) {
43
+ fs.mkdirSync(dest, { recursive: true });
44
+ fs.cpSync(templateDir, dest, { recursive: true });
45
+ for (const [from, to] of Object.entries(RENAMED_FILES)) {
46
+ const source = path.join(dest, from);
47
+ if (fs.existsSync(source))
48
+ fs.renameSync(source, path.join(dest, to));
49
+ }
50
+ for (const file of walk(dest)) {
51
+ const buffer = fs.readFileSync(file);
52
+ if (buffer.includes(0))
53
+ continue; // binary, leave it alone
54
+ const original = buffer.toString("utf8");
55
+ const rewritten = original
56
+ // A Windows checkout with core.autocrlf has CRLF, which the scaffold's
57
+ // Biome config (LF) would reject on its first lint.
58
+ .replaceAll("\r\n", "\n")
59
+ .replaceAll(`@${TEMPLATE_SCOPE}/`, `@${scope}/`)
60
+ .replaceAll(TEMPLATE_NAME, name);
61
+ if (rewritten !== original)
62
+ fs.writeFileSync(file, rewritten);
63
+ }
64
+ writeEnv(dest);
65
+ }
66
+ /**
67
+ * Creates `.env` from `.env.example` with fresh secrets, so the project runs
68
+ * without any manual setup.
69
+ */
70
+ export function writeEnv(dest) {
71
+ const example = path.join(dest, ".env.example");
72
+ const target = path.join(dest, ".env");
73
+ if (!fs.existsSync(example) || fs.existsSync(target))
74
+ return;
75
+ const dbPassword = randomBytes(16).toString("hex");
76
+ const authSecret = randomBytes(32).toString("base64url");
77
+ const lines = fs
78
+ .readFileSync(example, "utf8")
79
+ .split("\n")
80
+ .map((line) => {
81
+ if (line.startsWith("POSTGRES_PASSWORD="))
82
+ return `POSTGRES_PASSWORD=${dbPassword}`;
83
+ if (line.startsWith("BETTER_AUTH_SECRET="))
84
+ return `BETTER_AUTH_SECRET=${authSecret}`;
85
+ if (line.startsWith("DATABASE_URL=")) {
86
+ const url = new URL(line.slice("DATABASE_URL=".length));
87
+ url.password = dbPassword;
88
+ return `DATABASE_URL=${url.toString()}`;
89
+ }
90
+ return line;
91
+ });
92
+ fs.writeFileSync(target, lines.join("\n"));
93
+ }
94
+ function* walk(dir) {
95
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
96
+ const full = path.join(dir, entry.name);
97
+ if (entry.isDirectory()) {
98
+ if (entry.name === "node_modules" || entry.name === ".git")
99
+ continue;
100
+ yield* walk(full);
101
+ }
102
+ else if (entry.isFile()) {
103
+ yield full;
104
+ }
105
+ }
106
+ }
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "create-pyro",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Bootstrap new projects with Pyro.",
5
5
  "type": "module",
6
- "packageManager": "pnpm@11.9.0",
6
+ "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61",
7
7
  "bin": {
8
8
  "create-pyro": "./dist/index.js"
9
9
  },
@@ -41,14 +41,14 @@
41
41
  "prompts": "^2.4.2"
42
42
  },
43
43
  "devDependencies": {
44
- "@biomejs/biome": "^2.5.1",
45
- "@types/node": "^26.0.1",
44
+ "@biomejs/biome": "^2.6.3",
45
+ "@types/node": "^26.6.5",
46
46
  "@types/prompts": "^2.4.9",
47
- "typescript": "^6.0.3",
48
- "vitest": "^4.1.9"
47
+ "typescript": "^7.0.2",
48
+ "vitest": "^5.0.5"
49
49
  },
50
50
  "engines": {
51
51
  "node": ">=24",
52
- "pnpm": ">=11"
52
+ "pnpm": ">=12"
53
53
  }
54
54
  }
@@ -0,0 +1,30 @@
1
+ # deps
2
+ node_modules
3
+ **/node_modules
4
+
5
+ # build outputs
6
+ **/dist
7
+ **/.react-router
8
+
9
+ # env and logs
10
+ **/.env
11
+ **/.env.*
12
+ !**/.env.example
13
+ **/*.log
14
+
15
+ # turbo
16
+ .turbo
17
+ **/.turbo
18
+
19
+ # vcs
20
+ .git
21
+ .gitignore
22
+
23
+ # editor
24
+ .vscode
25
+
26
+ # misc
27
+ .DS_Store
28
+ Thumbs.db
29
+ *.tsbuildinfo
30
+ **/*.md
@@ -0,0 +1,23 @@
1
+ # Postgres (docker compose)
2
+ POSTGRES_USER=app
3
+ POSTGRES_PASSWORD=password
4
+ POSTGRES_DB=app
5
+
6
+ # Database (compose.dev.yml publishes Postgres on 15432)
7
+ DATABASE_URL=postgresql://app:password@localhost:15432/app
8
+
9
+ # Auth
10
+ BETTER_AUTH_URL=http://localhost:3002
11
+ BETTER_AUTH_SECRET=
12
+
13
+ # API
14
+ NODE_ENV=development
15
+ API_PORT=3002
16
+ CORS_ORIGIN=http://localhost:3000
17
+
18
+ # Web
19
+ PUBLIC_API_URL=http://localhost:3002
20
+ INTERNAL_API_URL=http://localhost:3002
21
+
22
+ # Prod only (compose.yml): the origin nginx is served on
23
+ PUBLIC_ORIGIN=http://localhost
@@ -0,0 +1,2 @@
1
+ # Keep LF everywhere, including Windows checkouts (Biome formats with LF).
2
+ * text=auto eol=lf
@@ -0,0 +1,43 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ check:
10
+ runs-on: ubuntu-latest
11
+ env:
12
+ DATABASE_URL: postgresql://app:password@localhost:15432/app
13
+ steps:
14
+ - uses: actions/checkout@v7
15
+
16
+ - name: Start Postgres
17
+ run: docker compose -f compose.dev.yml up -d --wait
18
+
19
+ - uses: pnpm/action-setup@v6
20
+
21
+ - uses: actions/setup-node@v6
22
+ with:
23
+ node-version: 24
24
+ cache: pnpm
25
+
26
+ - name: Install dependencies
27
+ run: pnpm install --frozen-lockfile
28
+
29
+ - name: Build
30
+ run: pnpm build
31
+
32
+ - name: Lint
33
+ run: pnpm lint:ci
34
+
35
+ - name: Typecheck
36
+ run: pnpm typecheck
37
+
38
+ - name: Test
39
+ run: pnpm test
40
+
41
+ - name: Postgres logs on failure
42
+ if: failure()
43
+ run: docker compose -f compose.dev.yml logs --tail 100
@@ -0,0 +1,3 @@
1
+ {
2
+ "recommendations": ["biomejs.biome"]
3
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "editor.defaultFormatter": "biomejs.biome",
3
+ "editor.formatOnSave": true,
4
+ "editor.codeActionsOnSave": {
5
+ "source.fixAll.biome": "explicit",
6
+ "source.organizeImports.biome": "explicit"
7
+ }
8
+ }
@@ -1,14 +1,67 @@
1
- # Create Pyro App
1
+ # my-pyro-app
2
2
 
3
- **pnpm**:
3
+ A TypeScript monorepo: a Hono api and a React Router web app sharing typed packages, with Postgres via Drizzle and auth via Better Auth.
4
+
5
+ ## Quick start
6
+
7
+ Requires Node.js 24+, pnpm 12+ and Docker.
4
8
 
5
9
  ```bash
6
10
  pnpm install
11
+ docker compose -f compose.dev.yml up -d # Postgres on localhost:15432
12
+ pnpm db:migrate
7
13
  pnpm dev
8
14
  ```
9
15
 
10
- **Docker**:
16
+ Then open http://localhost:3000. The api runs on http://localhost:3002.
17
+
18
+ `.env` is gitignored, so a fresh clone needs one: copy `.env.example` to `.env` and set `BETTER_AUTH_SECRET` (`openssl rand -base64 32`).
19
+
20
+ ## Layout
21
+
22
+ ```
23
+ apps/
24
+ api/ Hono server: Better Auth at /api/auth/*, your routes under /api
25
+ web/ React Router (SSR) + Tailwind; calls the api with a typed client
26
+ packages/
27
+ config/ Zod-validated env, split into lazily-checked scopes
28
+ db/ Drizzle schema, client and migrations
29
+ shared/ Types and Zod schemas used by both apps
30
+ infra/nginx/ Reverse proxy for the production compose stack
31
+ ```
32
+
33
+ Packages build to `dist/` with tsdown. During `pnpm dev`, each package rebuilds on change and the apps pick it up.
34
+
35
+ ## Scripts
36
+
37
+ | Script | What it does |
38
+ | ------------------ | ---------------------------------------------- |
39
+ | `pnpm dev` | Run everything in watch mode |
40
+ | `pnpm build` | Build all packages and apps |
41
+ | `pnpm typecheck` | Typecheck every workspace |
42
+ | `pnpm test` | Run every suite (the api suite needs Postgres) |
43
+ | `pnpm lint` | Lint and format with Biome, applying fixes |
44
+ | `pnpm db:generate` | Generate a migration from schema changes |
45
+ | `pnpm db:migrate` | Apply pending migrations |
46
+ | `pnpm db:push` | Push the schema directly (prototyping only) |
47
+ | `pnpm db:studio` | Open Drizzle Studio |
48
+
49
+ ## Environment
50
+
51
+ Every variable lives in the root `.env`; `.env.example` documents them. `packages/config` validates them per scope (`dbEnv`, `authEnv`, `apiEnv`, `webEnv`), so a process only needs the variables it reads, and a missing one fails with a message naming it.
52
+
53
+ The api tests run against a separate database: `DATABASE_URL` with `_test` appended to its name, created and migrated automatically. Set `TEST_DATABASE_URL` to use another one.
54
+
55
+ ## Auth
56
+
57
+ Email and password sign-in is on by default. To add Google or another provider, add its credentials to `authSchema` in `packages/config`, then enable it under `socialProviders` in `apps/api/src/lib/auth.ts`.
58
+
59
+ ## Production
11
60
 
12
61
  ```bash
13
- docker compose up --build
62
+ docker compose up --build -d
14
63
  ```
64
+
65
+ This builds the images and starts Postgres, runs migrations once, then starts the api and web behind nginx on port 80. Set `PUBLIC_ORIGIN` to the URL you serve it on.
66
+
67
+ The Dockerfiles list each workspace's `package.json` explicitly, so when you add or remove an app or package, update the `COPY` lines in each Dockerfile.
@@ -0,0 +1,46 @@
1
+ FROM node:24-alpine AS builder
2
+ WORKDIR /app
3
+
4
+ COPY package.json pnpm-workspace.yaml pnpm-lock.yaml tsconfig.json ./
5
+ COPY apps/api/package.json ./apps/api/
6
+ COPY apps/web/package.json ./apps/web/
7
+ COPY packages/config/package.json ./packages/config/
8
+ COPY packages/db/package.json ./packages/db/
9
+ COPY packages/shared/package.json ./packages/shared/
10
+
11
+ RUN corepack enable && pnpm install --frozen-lockfile --filter @repo/api...
12
+
13
+ COPY apps/api ./apps/api
14
+ COPY packages/config ./packages/config
15
+ COPY packages/db ./packages/db
16
+ COPY packages/shared ./packages/shared
17
+
18
+ RUN pnpm --filter @repo/api... build
19
+
20
+ FROM node:24-alpine
21
+ WORKDIR /app
22
+
23
+ COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
24
+ COPY apps/api/package.json ./apps/api/
25
+ COPY apps/web/package.json ./apps/web/
26
+ COPY packages/config/package.json ./packages/config/
27
+ COPY packages/db/package.json ./packages/db/
28
+ COPY packages/shared/package.json ./packages/shared/
29
+
30
+ RUN corepack enable && pnpm install --frozen-lockfile --filter @repo/api... --prod
31
+
32
+ COPY --from=builder /app/packages/config/dist ./packages/config/dist
33
+ COPY --from=builder /app/packages/db/dist ./packages/db/dist
34
+ COPY --from=builder /app/packages/shared/dist ./packages/shared/dist
35
+ COPY --from=builder /app/apps/api/dist ./apps/api/dist
36
+
37
+ ENV NODE_ENV=production
38
+ ENV API_PORT=3002
39
+ EXPOSE 3002
40
+
41
+ USER node
42
+
43
+ HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
44
+ CMD node -e "fetch('http://127.0.0.1:3002/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
45
+
46
+ CMD ["node", "apps/api/dist/index.mjs"]