create-softeneers-app 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.html +56 -0
- package/README.md +16 -8
- package/dist/args.js +33 -4
- package/dist/args.js.map +1 -1
- package/dist/fragments.js +127 -0
- package/dist/fragments.js.map +1 -0
- package/dist/index.js +18 -7
- package/dist/index.js.map +1 -1
- package/dist/prompts.js +27 -1
- package/dist/prompts.js.map +1 -1
- package/dist/templates.js +33 -8
- package/dist/templates.js.map +1 -1
- package/package.json +2 -2
- package/templates/express-api/.env.example +13 -0
- package/templates/express-api/README.md +77 -0
- package/templates/express-api/docker-compose.yml +15 -0
- package/templates/express-api/package.json +36 -0
- package/templates/express-api/softeneers.template.json +17 -0
- package/templates/express-api/src/auth/auth.ts +11 -0
- package/templates/express-api/src/cars/routes.ts +58 -0
- package/templates/express-api/src/cars/store.ts +92 -0
- package/templates/express-api/src/cars/types.ts +8 -0
- package/templates/express-api/src/cars/validate.ts +16 -0
- package/templates/express-api/src/db.ts +13 -0
- package/templates/express-api/src/env.ts +23 -0
- package/templates/express-api/src/index.ts +55 -0
- package/templates/express-api/src/scripts/migrate.ts +13 -0
- package/templates/express-api/src/scripts/seed.ts +25 -0
- package/templates/express-api/test/validate.test.ts +25 -0
- package/templates/express-api/tsconfig.json +14 -0
- package/templates/hono-api/.env.example +13 -0
- package/templates/hono-api/README.md +77 -0
- package/templates/hono-api/docker-compose.yml +15 -0
- package/templates/hono-api/package.json +34 -0
- package/templates/hono-api/softeneers.template.json +17 -0
- package/templates/hono-api/src/auth/auth.ts +11 -0
- package/templates/hono-api/src/cars/routes.ts +43 -0
- package/templates/hono-api/src/cars/store.ts +92 -0
- package/templates/hono-api/src/cars/types.ts +8 -0
- package/templates/hono-api/src/cars/validate.ts +16 -0
- package/templates/hono-api/src/db.ts +13 -0
- package/templates/hono-api/src/env.ts +23 -0
- package/templates/hono-api/src/index.ts +44 -0
- package/templates/hono-api/src/scripts/migrate.ts +13 -0
- package/templates/hono-api/src/scripts/seed.ts +25 -0
- package/templates/hono-api/test/validate.test.ts +25 -0
- package/templates/hono-api/tsconfig.json +14 -0
- package/templates/minimal/.env.example +2 -0
- package/templates/minimal/README.md +33 -0
- package/templates/minimal/package.json +22 -0
- package/templates/minimal/src/index.ts +20 -0
- package/templates/minimal/test/greet.test.ts +12 -0
- package/templates/minimal/tsconfig.json +15 -0
- package/templates/tanstack-start/.env.example +11 -0
- package/templates/tanstack-start/README.md +73 -0
- package/templates/tanstack-start/docker-compose.yml +15 -0
- package/templates/tanstack-start/package.json +56 -0
- package/templates/tanstack-start/public/favicon.ico +0 -0
- package/templates/tanstack-start/public/logo192.png +0 -0
- package/templates/tanstack-start/public/logo512.png +0 -0
- package/templates/tanstack-start/public/manifest.json +25 -0
- package/templates/tanstack-start/public/robots.txt +3 -0
- package/templates/tanstack-start/softeneers.template.json +17 -0
- package/templates/tanstack-start/src/cars/types.ts +8 -0
- package/templates/tanstack-start/src/cars/validate.ts +16 -0
- package/templates/tanstack-start/src/router.tsx +19 -0
- package/templates/tanstack-start/src/routes/__root.tsx +54 -0
- package/templates/tanstack-start/src/routes/api/auth/$.ts +14 -0
- package/templates/tanstack-start/src/routes/cars.tsx +87 -0
- package/templates/tanstack-start/src/routes/index.tsx +20 -0
- package/templates/tanstack-start/src/server/auth.ts +11 -0
- package/templates/tanstack-start/src/server/cars.ts +27 -0
- package/templates/tanstack-start/src/server/db.ts +12 -0
- package/templates/tanstack-start/src/server/env.ts +22 -0
- package/templates/tanstack-start/src/server/scripts/migrate.ts +13 -0
- package/templates/tanstack-start/src/server/scripts/seed.ts +25 -0
- package/templates/tanstack-start/src/server/store.ts +79 -0
- package/templates/tanstack-start/src/styles.css +17 -0
- package/templates/tanstack-start/tsconfig.json +28 -0
- package/templates/tanstack-start/tsr.config.json +3 -0
- package/templates/tanstack-start/vite.config.ts +14 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type { Car, NewCar } from "./types.js";
|
|
2
|
+
|
|
3
|
+
// The data layer behind the CRUD routes. With the `db` toggle on this is backed
|
|
4
|
+
// by MySQL (Sequelize via @softeneers/db); with it off it's an in-memory Map so
|
|
5
|
+
// the API runs with zero infrastructure. Both satisfy the same CarStore.
|
|
6
|
+
export interface CarStore {
|
|
7
|
+
list(): Promise<Car[]>;
|
|
8
|
+
get(id: number): Promise<Car | null>;
|
|
9
|
+
create(input: NewCar): Promise<Car>;
|
|
10
|
+
update(id: number, input: NewCar): Promise<Car | null>;
|
|
11
|
+
remove(id: number): Promise<boolean>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// #if db
|
|
15
|
+
import { DataTypes, Model } from "@softeneers/db";
|
|
16
|
+
|
|
17
|
+
import { sequelize } from "../db.js";
|
|
18
|
+
|
|
19
|
+
class CarModel extends Model {
|
|
20
|
+
declare id: number;
|
|
21
|
+
declare brand: string;
|
|
22
|
+
declare model: string;
|
|
23
|
+
declare year: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
CarModel.init(
|
|
27
|
+
{
|
|
28
|
+
id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true },
|
|
29
|
+
brand: { type: DataTypes.STRING, allowNull: false },
|
|
30
|
+
model: { type: DataTypes.STRING, allowNull: false },
|
|
31
|
+
year: { type: DataTypes.INTEGER, allowNull: false },
|
|
32
|
+
},
|
|
33
|
+
{ sequelize, modelName: "Car", tableName: "cars" },
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
export { CarModel };
|
|
37
|
+
|
|
38
|
+
const toCar = (m: CarModel): Car => ({ id: m.id, brand: m.brand, model: m.model, year: m.year });
|
|
39
|
+
|
|
40
|
+
export const carStore: CarStore = {
|
|
41
|
+
async list() {
|
|
42
|
+
return (await CarModel.findAll({ order: [["id", "ASC"]] })).map(toCar);
|
|
43
|
+
},
|
|
44
|
+
async get(id) {
|
|
45
|
+
const m = await CarModel.findByPk(id);
|
|
46
|
+
return m ? toCar(m) : null;
|
|
47
|
+
},
|
|
48
|
+
async create(input) {
|
|
49
|
+
return toCar(await CarModel.create(input));
|
|
50
|
+
},
|
|
51
|
+
async update(id, input) {
|
|
52
|
+
const m = await CarModel.findByPk(id);
|
|
53
|
+
if (!m) return null;
|
|
54
|
+
await m.update(input);
|
|
55
|
+
return toCar(m);
|
|
56
|
+
},
|
|
57
|
+
async remove(id) {
|
|
58
|
+
const m = await CarModel.findByPk(id);
|
|
59
|
+
if (!m) return false;
|
|
60
|
+
await m.destroy();
|
|
61
|
+
return true;
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
// #endif
|
|
65
|
+
// #if !db
|
|
66
|
+
let nextId = 1;
|
|
67
|
+
const cars = new Map<number, Car>();
|
|
68
|
+
|
|
69
|
+
export const carStore: CarStore = {
|
|
70
|
+
async list() {
|
|
71
|
+
return [...cars.values()].sort((a, b) => a.id - b.id);
|
|
72
|
+
},
|
|
73
|
+
async get(id) {
|
|
74
|
+
return cars.get(id) ?? null;
|
|
75
|
+
},
|
|
76
|
+
async create(input) {
|
|
77
|
+
const car: Car = { id: nextId++, ...input };
|
|
78
|
+
cars.set(car.id, car);
|
|
79
|
+
return car;
|
|
80
|
+
},
|
|
81
|
+
async update(id, input) {
|
|
82
|
+
const car = cars.get(id);
|
|
83
|
+
if (!car) return null;
|
|
84
|
+
const next: Car = { ...car, ...input };
|
|
85
|
+
cars.set(id, next);
|
|
86
|
+
return next;
|
|
87
|
+
},
|
|
88
|
+
async remove(id) {
|
|
89
|
+
return cars.delete(id);
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
// #endif
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { NewCar } from "./types.js";
|
|
2
|
+
|
|
3
|
+
/** Thrown when a request body is not a valid car. Mapped to HTTP 400. */
|
|
4
|
+
export class ValidationError extends Error {}
|
|
5
|
+
|
|
6
|
+
/** Validate and normalize an untrusted request body into a NewCar. */
|
|
7
|
+
export function parseNewCar(body: unknown): NewCar {
|
|
8
|
+
const b = (body ?? {}) as Record<string, unknown>;
|
|
9
|
+
const brand = typeof b.brand === "string" ? b.brand.trim() : "";
|
|
10
|
+
const model = typeof b.model === "string" ? b.model.trim() : "";
|
|
11
|
+
const year = Number(b.year);
|
|
12
|
+
if (!brand || !model || !Number.isInteger(year)) {
|
|
13
|
+
throw new ValidationError("brand (string), model (string) and year (integer) are required.");
|
|
14
|
+
}
|
|
15
|
+
return { brand, model, year };
|
|
16
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { createDb } from "@softeneers/db";
|
|
2
|
+
|
|
3
|
+
import { env } from "./env.js";
|
|
4
|
+
|
|
5
|
+
// A configured (but not-yet-connected) Sequelize instance. Importing a model
|
|
6
|
+
// registers it; `assertConnection`/`sync` open the connection on startup.
|
|
7
|
+
export const sequelize = createDb({
|
|
8
|
+
host: env.DB_HOST,
|
|
9
|
+
port: env.DB_PORT,
|
|
10
|
+
database: env.DB_NAME,
|
|
11
|
+
username: env.DB_USER,
|
|
12
|
+
password: env.DB_PASSWORD,
|
|
13
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import "dotenv/config";
|
|
2
|
+
|
|
3
|
+
import { createEnv, z } from "@softeneers/env";
|
|
4
|
+
|
|
5
|
+
// Validated, typed environment. Boot fails fast with a readable message if the
|
|
6
|
+
// .env is wrong. Variables for a feature exist only when that toggle is on.
|
|
7
|
+
export const env = createEnv({
|
|
8
|
+
schema: {
|
|
9
|
+
PORT: z.coerce.number().default(4000),
|
|
10
|
+
CORS_ORIGIN: z.string().default("http://localhost:3000"),
|
|
11
|
+
// #if db
|
|
12
|
+
DB_HOST: z.string().default("127.0.0.1"),
|
|
13
|
+
DB_PORT: z.coerce.number().default(3306),
|
|
14
|
+
DB_NAME: z.string().default("app_dev"),
|
|
15
|
+
DB_USER: z.string().default("root"),
|
|
16
|
+
DB_PASSWORD: z.string().default(""),
|
|
17
|
+
// #endif
|
|
18
|
+
// #if auth
|
|
19
|
+
AUTH_SECRET: z.string().min(16).default("dev-secret-change-me-to-a-long-random-string"),
|
|
20
|
+
AUTH_BASE_URL: z.string().default("http://localhost:4000"),
|
|
21
|
+
// #endif
|
|
22
|
+
},
|
|
23
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { serve } from "@hono/node-server";
|
|
2
|
+
import { Hono } from "hono";
|
|
3
|
+
import { cors } from "hono/cors";
|
|
4
|
+
|
|
5
|
+
import { cars } from "./cars/routes.js";
|
|
6
|
+
import { env } from "./env.js";
|
|
7
|
+
// #if db
|
|
8
|
+
import { assertConnection } from "@softeneers/db";
|
|
9
|
+
|
|
10
|
+
import { sequelize } from "./db.js";
|
|
11
|
+
import "./cars/store.js"; // registers the Car model on the connection
|
|
12
|
+
// #endif
|
|
13
|
+
// #if auth
|
|
14
|
+
import { auth } from "./auth/auth.js";
|
|
15
|
+
// #endif
|
|
16
|
+
|
|
17
|
+
const app = new Hono();
|
|
18
|
+
|
|
19
|
+
app.use("*", cors({ origin: env.CORS_ORIGIN }));
|
|
20
|
+
|
|
21
|
+
app.get("/health", (c) => c.json({ ok: true }));
|
|
22
|
+
|
|
23
|
+
app.route("/api/cars", cars);
|
|
24
|
+
|
|
25
|
+
// #if auth
|
|
26
|
+
// better-auth speaks the Fetch API, so hand it the raw Request and return its Response.
|
|
27
|
+
app.on(["GET", "POST"], "/api/auth/*", (c) => auth.handler(c.req.raw));
|
|
28
|
+
// #endif
|
|
29
|
+
|
|
30
|
+
async function start(): Promise<void> {
|
|
31
|
+
// #if db
|
|
32
|
+
await assertConnection(sequelize);
|
|
33
|
+
await sequelize.sync();
|
|
34
|
+
console.log("Database connected.");
|
|
35
|
+
// #endif
|
|
36
|
+
serve({ fetch: app.fetch, port: env.PORT }, (info) => {
|
|
37
|
+
console.log(`API running on http://localhost:${info.port}`);
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
start().catch((error) => {
|
|
42
|
+
console.error("Failed to start:", error);
|
|
43
|
+
process.exit(1);
|
|
44
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import "dotenv/config";
|
|
2
|
+
|
|
3
|
+
import { assertConnection } from "@softeneers/db";
|
|
4
|
+
|
|
5
|
+
import { sequelize } from "../db.js";
|
|
6
|
+
import "../cars/store.js"; // importing the store registers the Car model
|
|
7
|
+
|
|
8
|
+
const reset = process.argv.includes("--reset");
|
|
9
|
+
|
|
10
|
+
await assertConnection(sequelize);
|
|
11
|
+
await sequelize.sync({ force: reset });
|
|
12
|
+
console.log(reset ? "Database reset — tables recreated." : "Database synced — tables ensured.");
|
|
13
|
+
await sequelize.close();
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import "dotenv/config";
|
|
2
|
+
|
|
3
|
+
import { assertConnection } from "@softeneers/db";
|
|
4
|
+
|
|
5
|
+
import { carStore } from "../cars/store.js";
|
|
6
|
+
import { sequelize } from "../db.js";
|
|
7
|
+
|
|
8
|
+
await assertConnection(sequelize);
|
|
9
|
+
await sequelize.sync();
|
|
10
|
+
|
|
11
|
+
const existing = await carStore.list();
|
|
12
|
+
if (existing.length > 0) {
|
|
13
|
+
console.log(`Skipped seeding — ${existing.length} cars already present.`);
|
|
14
|
+
} else {
|
|
15
|
+
for (const car of [
|
|
16
|
+
{ brand: "Toyota", model: "Corolla", year: 2021 },
|
|
17
|
+
{ brand: "Tesla", model: "Model 3", year: 2023 },
|
|
18
|
+
{ brand: "Ford", model: "Mustang", year: 1969 },
|
|
19
|
+
]) {
|
|
20
|
+
await carStore.create(car);
|
|
21
|
+
}
|
|
22
|
+
console.log("Seeded 3 cars.");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
await sequelize.close();
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
|
|
4
|
+
import { parseNewCar, ValidationError } from "../src/cars/validate.ts";
|
|
5
|
+
|
|
6
|
+
test("parses a valid car body", () => {
|
|
7
|
+
assert.deepEqual(parseNewCar({ brand: "Tesla", model: "Model 3", year: 2023 }), {
|
|
8
|
+
brand: "Tesla",
|
|
9
|
+
model: "Model 3",
|
|
10
|
+
year: 2023,
|
|
11
|
+
});
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("trims strings and coerces a numeric year", () => {
|
|
15
|
+
assert.deepEqual(parseNewCar({ brand: " Ford ", model: " Mustang ", year: "1969" }), {
|
|
16
|
+
brand: "Ford",
|
|
17
|
+
model: "Mustang",
|
|
18
|
+
year: 1969,
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("rejects an incomplete body", () => {
|
|
23
|
+
assert.throws(() => parseNewCar({ brand: "Toyota" }), ValidationError);
|
|
24
|
+
assert.throws(() => parseNewCar({}), ValidationError);
|
|
25
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"rootDir": "src",
|
|
7
|
+
"outDir": "dist",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"forceConsistentCasingInFileNames": true,
|
|
11
|
+
"skipLibCheck": true
|
|
12
|
+
},
|
|
13
|
+
"include": ["src"]
|
|
14
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# {{PROJECT_NAME}}
|
|
2
|
+
|
|
3
|
+
A zero-framework **Node + TypeScript** starter generated by
|
|
4
|
+
[`create-softeneers-app`](https://www.npmjs.com/package/create-softeneers-app).
|
|
5
|
+
A clean blank slate: typed source in `src/`, a `node:test` suite, and a `tsc`
|
|
6
|
+
build — nothing else to unlearn.
|
|
7
|
+
|
|
8
|
+
## Scripts
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
npm run dev # run src/index.ts with watch (tsx)
|
|
12
|
+
npm run build # type-check + emit dist/ (tsc)
|
|
13
|
+
npm start # run the built dist/index.js
|
|
14
|
+
npm run typecheck # tsc --noEmit
|
|
15
|
+
npm test # node:test suite (via tsx)
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Layout
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
src/index.ts your program (exports greet() as an example)
|
|
22
|
+
test/greet.test.ts the test suite
|
|
23
|
+
tsconfig.json strict TypeScript, NodeNext modules
|
|
24
|
+
.env.example copied to .env on generation (GREET_NAME)
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Try it
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
npm install
|
|
31
|
+
npm test
|
|
32
|
+
npm run dev -- Ada # → "Hello, Ada!"
|
|
33
|
+
```
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "minimal",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"description": "Zero-framework Node + TypeScript starter generated by create-softeneers-app.",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=18"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"dev": "tsx watch src/index.ts",
|
|
12
|
+
"build": "tsc",
|
|
13
|
+
"start": "node dist/index.js",
|
|
14
|
+
"typecheck": "tsc --noEmit",
|
|
15
|
+
"test": "node --import tsx --test test/*.test.ts"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"@types/node": "^20.0.0",
|
|
19
|
+
"tsx": "^4.19.0",
|
|
20
|
+
"typescript": "^5.6.0"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Minimal Node + TypeScript starter. Replace this with your program — the
|
|
2
|
+
// structure (typed source in src/, a node:test suite, tsc build) is the point.
|
|
3
|
+
|
|
4
|
+
export interface GreetOptions {
|
|
5
|
+
/** Who to greet. Defaults to "world". */
|
|
6
|
+
name?: string;
|
|
7
|
+
/** Greeting word. Defaults to "Hello". */
|
|
8
|
+
greeting?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function greet({ name = "world", greeting = "Hello" }: GreetOptions = {}): string {
|
|
12
|
+
return `${greeting}, ${name}!`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Run directly (`npm run dev` / `npm start`) — but stay importable from tests.
|
|
16
|
+
const invokedDirectly = process.argv[1] && import.meta.url === `file://${process.argv[1]}`;
|
|
17
|
+
if (invokedDirectly) {
|
|
18
|
+
const name = process.argv[2] ?? process.env.GREET_NAME;
|
|
19
|
+
console.log(greet({ name }));
|
|
20
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
|
|
4
|
+
import { greet } from "../src/index.ts";
|
|
5
|
+
|
|
6
|
+
test("greets the world by default", () => {
|
|
7
|
+
assert.equal(greet(), "Hello, world!");
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test("uses the provided name and greeting", () => {
|
|
11
|
+
assert.equal(greet({ name: "Ada", greeting: "Hi" }), "Hi, Ada!");
|
|
12
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"rootDir": "src",
|
|
7
|
+
"outDir": "dist",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"declaration": true,
|
|
10
|
+
"esModuleInterop": true,
|
|
11
|
+
"forceConsistentCasingInFileNames": true,
|
|
12
|
+
"skipLibCheck": true
|
|
13
|
+
},
|
|
14
|
+
"include": ["src"]
|
|
15
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# {{PROJECT_NAME}}
|
|
2
|
+
|
|
3
|
+
A **TanStack Start** (fullstack React) app generated by
|
|
4
|
+
[`create-softeneers-app`](https://www.npmjs.com/package/create-softeneers-app),
|
|
5
|
+
with a full cars CRUD demo wired through type-safe **server functions**.
|
|
6
|
+
|
|
7
|
+
## Scripts
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm run dev # Vite dev server on http://localhost:3000
|
|
11
|
+
npm run build # production build (client + SSR)
|
|
12
|
+
npm start # preview the production build
|
|
13
|
+
npm run typecheck # generate routes + tsc --noEmit
|
|
14
|
+
npm test # vitest
|
|
15
|
+
# #if db
|
|
16
|
+
npm run db:migrate # create/sync tables
|
|
17
|
+
npm run db:seed # insert sample cars
|
|
18
|
+
npm run db:reset # drop, recreate, reseed
|
|
19
|
+
# #endif
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Structure
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
src/routes/index.tsx home page
|
|
26
|
+
src/routes/cars.tsx cars CRUD UI (loader + server functions)
|
|
27
|
+
src/server/cars.ts createServerFn RPCs (list / create / remove)
|
|
28
|
+
src/server/store.ts data layer (MySQL or in-memory)
|
|
29
|
+
# #if auth
|
|
30
|
+
src/routes/api/auth/$.ts better-auth catch-all route (/api/auth/*)
|
|
31
|
+
src/server/auth.ts better-auth instance
|
|
32
|
+
# #endif
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The `/cars` page loads data with a route `loader` and mutates via server
|
|
36
|
+
functions — no hand-written API client, end-to-end type safety.
|
|
37
|
+
# #if db
|
|
38
|
+
|
|
39
|
+
## Database
|
|
40
|
+
|
|
41
|
+
Persistence is MySQL via Sequelize ([`@softeneers/db`](https://www.npmjs.com/package/@softeneers/db)).
|
|
42
|
+
Set `DB_*` in `.env`, then:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
# #if docker
|
|
46
|
+
docker compose up -d # start MySQL
|
|
47
|
+
# #endif
|
|
48
|
+
npm run db:migrate && npm run db:seed
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Generate with `--no-db` to use a dependency-free in-memory store instead.
|
|
52
|
+
# #endif
|
|
53
|
+
# #if auth
|
|
54
|
+
|
|
55
|
+
## Authentication
|
|
56
|
+
|
|
57
|
+
Email + password auth via better-auth ([`@softeneers/auth`](https://www.npmjs.com/package/@softeneers/auth)),
|
|
58
|
+
served at `/api/auth/*`. Set a strong `AUTH_SECRET` in `.env`, and add the
|
|
59
|
+
`better-auth/react` client in your components to drive sign-in/up flows.
|
|
60
|
+
# #endif
|
|
61
|
+
|
|
62
|
+
## Getting started
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
npm install
|
|
66
|
+
# #if docker
|
|
67
|
+
docker compose up -d
|
|
68
|
+
# #endif
|
|
69
|
+
# #if db
|
|
70
|
+
npm run db:migrate && npm run db:seed
|
|
71
|
+
# #endif
|
|
72
|
+
npm run dev
|
|
73
|
+
```
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
services:
|
|
2
|
+
db:
|
|
3
|
+
image: mysql:8
|
|
4
|
+
restart: unless-stopped
|
|
5
|
+
environment:
|
|
6
|
+
MYSQL_DATABASE: ${DB_NAME:-app_dev}
|
|
7
|
+
MYSQL_ROOT_PASSWORD: ${DB_PASSWORD:-}
|
|
8
|
+
MYSQL_ALLOW_EMPTY_PASSWORD: "yes"
|
|
9
|
+
ports:
|
|
10
|
+
- "${DB_PORT:-3306}:3306"
|
|
11
|
+
volumes:
|
|
12
|
+
- db_data:/var/lib/mysql
|
|
13
|
+
|
|
14
|
+
volumes:
|
|
15
|
+
db_data:
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "tanstack-start",
|
|
3
|
+
"private": true,
|
|
4
|
+
"type": "module",
|
|
5
|
+
"imports": {
|
|
6
|
+
"#/*": "./src/*"
|
|
7
|
+
},
|
|
8
|
+
"scripts": {
|
|
9
|
+
"dev": "vite dev --port 3000",
|
|
10
|
+
"generate-routes": "tsr generate",
|
|
11
|
+
"build": "vite build",
|
|
12
|
+
"start": "vite preview --port 3000",
|
|
13
|
+
"typecheck": "tsr generate && tsc --noEmit",
|
|
14
|
+
"db:migrate": "tsx src/server/scripts/migrate.ts",
|
|
15
|
+
"db:seed": "tsx src/server/scripts/seed.ts",
|
|
16
|
+
"db:reset": "tsx src/server/scripts/migrate.ts --reset && tsx src/server/scripts/seed.ts",
|
|
17
|
+
"test": "vitest run --passWithNoTests"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@softeneers/auth": "^0.1.0",
|
|
21
|
+
"@softeneers/db": "^0.1.0",
|
|
22
|
+
"@softeneers/env": "^0.1.0",
|
|
23
|
+
"@tailwindcss/vite": "^4.1.18",
|
|
24
|
+
"@tanstack/react-devtools": "latest",
|
|
25
|
+
"@tanstack/react-router": "latest",
|
|
26
|
+
"@tanstack/react-router-devtools": "latest",
|
|
27
|
+
"@tanstack/react-router-ssr-query": "latest",
|
|
28
|
+
"@tanstack/react-start": "latest",
|
|
29
|
+
"@tanstack/router-plugin": "^1.132.0",
|
|
30
|
+
"dotenv": "^16.4.5",
|
|
31
|
+
"lucide-react": "^0.545.0",
|
|
32
|
+
"mysql2": "^3.11.0",
|
|
33
|
+
"react": "^19.2.0",
|
|
34
|
+
"react-dom": "^19.2.0",
|
|
35
|
+
"tailwindcss": "^4.1.18"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@tailwindcss/typography": "^0.5.16",
|
|
39
|
+
"@tanstack/devtools-vite": "latest",
|
|
40
|
+
"@tanstack/router-cli": "^1.132.0",
|
|
41
|
+
"@testing-library/dom": "^10.4.1",
|
|
42
|
+
"@testing-library/react": "^16.3.0",
|
|
43
|
+
"@types/node": "^22.10.2",
|
|
44
|
+
"@types/react": "^19.2.0",
|
|
45
|
+
"@types/react-dom": "^19.2.0",
|
|
46
|
+
"@vitejs/plugin-react": "^6.0.1",
|
|
47
|
+
"jsdom": "^28.1.0",
|
|
48
|
+
"tsx": "^4.19.0",
|
|
49
|
+
"typescript": "^6.0.2",
|
|
50
|
+
"vite": "^8.0.0",
|
|
51
|
+
"vitest": "^4.1.5"
|
|
52
|
+
},
|
|
53
|
+
"pnpm": {
|
|
54
|
+
"onlyBuiltDependencies": ["esbuild", "lightningcss"]
|
|
55
|
+
}
|
|
56
|
+
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"short_name": "TanStack App",
|
|
3
|
+
"name": "Create TanStack App Sample",
|
|
4
|
+
"icons": [
|
|
5
|
+
{
|
|
6
|
+
"src": "favicon.ico",
|
|
7
|
+
"sizes": "64x64 32x32 24x24 16x16",
|
|
8
|
+
"type": "image/x-icon"
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"src": "logo192.png",
|
|
12
|
+
"type": "image/png",
|
|
13
|
+
"sizes": "192x192"
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"src": "logo512.png",
|
|
17
|
+
"type": "image/png",
|
|
18
|
+
"sizes": "512x512"
|
|
19
|
+
}
|
|
20
|
+
],
|
|
21
|
+
"start_url": ".",
|
|
22
|
+
"display": "standalone",
|
|
23
|
+
"theme_color": "#000000",
|
|
24
|
+
"background_color": "#ffffff"
|
|
25
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"toggles": { "db": true, "auth": false, "docker": true },
|
|
3
|
+
"fragments": {
|
|
4
|
+
"db": {
|
|
5
|
+
"removePaths": ["src/server/db.ts", "src/server/scripts", "docker-compose.yml"],
|
|
6
|
+
"removeDeps": ["@softeneers/db", "mysql2"],
|
|
7
|
+
"removeScripts": ["db:migrate", "db:seed", "db:reset"]
|
|
8
|
+
},
|
|
9
|
+
"auth": {
|
|
10
|
+
"removePaths": ["src/server/auth.ts", "src/routes/api"],
|
|
11
|
+
"removeDeps": ["@softeneers/auth"]
|
|
12
|
+
},
|
|
13
|
+
"docker": {
|
|
14
|
+
"removePaths": ["docker-compose.yml"]
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { NewCar } from './types'
|
|
2
|
+
|
|
3
|
+
/** Thrown when a submitted car is invalid. */
|
|
4
|
+
export class ValidationError extends Error {}
|
|
5
|
+
|
|
6
|
+
/** Validate and normalize untrusted input into a NewCar. Runs on the server. */
|
|
7
|
+
export function parseNewCar(input: unknown): NewCar {
|
|
8
|
+
const b = (input ?? {}) as Record<string, unknown>
|
|
9
|
+
const brand = typeof b.brand === 'string' ? b.brand.trim() : ''
|
|
10
|
+
const model = typeof b.model === 'string' ? b.model.trim() : ''
|
|
11
|
+
const year = Number(b.year)
|
|
12
|
+
if (!brand || !model || !Number.isInteger(year)) {
|
|
13
|
+
throw new ValidationError('brand (string), model (string) and year (integer) are required.')
|
|
14
|
+
}
|
|
15
|
+
return { brand, model, year }
|
|
16
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { createRouter as createTanStackRouter } from '@tanstack/react-router'
|
|
2
|
+
import { routeTree } from './routeTree.gen'
|
|
3
|
+
|
|
4
|
+
export function getRouter() {
|
|
5
|
+
const router = createTanStackRouter({
|
|
6
|
+
routeTree,
|
|
7
|
+
scrollRestoration: true,
|
|
8
|
+
defaultPreload: 'intent',
|
|
9
|
+
defaultPreloadStaleTime: 0,
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
return router
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
declare module '@tanstack/react-router' {
|
|
16
|
+
interface Register {
|
|
17
|
+
router: ReturnType<typeof getRouter>
|
|
18
|
+
}
|
|
19
|
+
}
|