create-softeneers-app 0.1.0 → 0.2.1
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/demo.ts +10 -0
- package/templates/express-api/src/cars/routes.ts +58 -0
- package/templates/express-api/src/cars/store.ts +150 -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 +37 -0
- package/templates/express-api/src/scripts/migrate.ts +13 -0
- package/templates/express-api/src/scripts/seed.ts +20 -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/demo.ts +10 -0
- package/templates/hono-api/src/cars/routes.ts +43 -0
- package/templates/hono-api/src/cars/store.ts +150 -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 +26 -0
- package/templates/hono-api/src/scripts/migrate.ts +13 -0
- package/templates/hono-api/src/scripts/seed.ts +20 -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 +74 -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/demo.ts +9 -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 +20 -0
- package/templates/tanstack-start/src/server/store.ts +132 -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,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "express-api",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"description": "Express + TypeScript REST API 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
|
+
"db:migrate": "tsx src/scripts/migrate.ts",
|
|
16
|
+
"db:seed": "tsx src/scripts/seed.ts",
|
|
17
|
+
"db:reset": "tsx src/scripts/migrate.ts --reset && tsx src/scripts/seed.ts",
|
|
18
|
+
"test": "node --import tsx --test test/*.test.ts"
|
|
19
|
+
},
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"@softeneers/auth": "^0.1.0",
|
|
22
|
+
"@softeneers/db": "^0.1.0",
|
|
23
|
+
"@softeneers/env": "^0.1.0",
|
|
24
|
+
"cors": "^2.8.5",
|
|
25
|
+
"dotenv": "^16.4.5",
|
|
26
|
+
"express": "^5.0.0",
|
|
27
|
+
"mysql2": "^3.11.0"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@types/cors": "^2.8.17",
|
|
31
|
+
"@types/express": "^5.0.0",
|
|
32
|
+
"@types/node": "^20.0.0",
|
|
33
|
+
"tsx": "^4.19.0",
|
|
34
|
+
"typescript": "^5.6.0"
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"toggles": { "db": false, "auth": false, "docker": false },
|
|
3
|
+
"fragments": {
|
|
4
|
+
"db": {
|
|
5
|
+
"removePaths": ["src/db.ts", "src/scripts", "docker-compose.yml"],
|
|
6
|
+
"removeDeps": ["@softeneers/db", "mysql2"],
|
|
7
|
+
"removeScripts": ["db:migrate", "db:seed", "db:reset"]
|
|
8
|
+
},
|
|
9
|
+
"auth": {
|
|
10
|
+
"removePaths": ["src/auth"],
|
|
11
|
+
"removeDeps": ["@softeneers/auth"]
|
|
12
|
+
},
|
|
13
|
+
"docker": {
|
|
14
|
+
"removePaths": ["docker-compose.yml"]
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { createAuth } from "@softeneers/auth";
|
|
2
|
+
|
|
3
|
+
import { env } from "../env.js";
|
|
4
|
+
|
|
5
|
+
// Email + password authentication via better-auth. With no `database` configured
|
|
6
|
+
// better-auth uses an in-memory store (fine for local dev); point it at your DB
|
|
7
|
+
// for persistence. Routes are mounted at /api/auth/* in src/index.ts.
|
|
8
|
+
export const auth = createAuth({
|
|
9
|
+
secret: env.AUTH_SECRET,
|
|
10
|
+
baseURL: env.AUTH_BASE_URL,
|
|
11
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { NewCar } from "./types.js";
|
|
2
|
+
|
|
3
|
+
// Demo garage inventory seeded on first run so `npm run dev` shows a working
|
|
4
|
+
// CRUD demo immediately — into MySQL when a database is reachable, otherwise
|
|
5
|
+
// into the in-memory store.
|
|
6
|
+
export const DEMO_CARS: NewCar[] = [
|
|
7
|
+
{ brand: "Toyota", model: "Corolla", year: 2021 },
|
|
8
|
+
{ brand: "Tesla", model: "Model 3", year: 2023 },
|
|
9
|
+
{ brand: "Ford", model: "Mustang", year: 1969 },
|
|
10
|
+
];
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { Router } from "express";
|
|
2
|
+
|
|
3
|
+
import { carStore } from "./store.js";
|
|
4
|
+
import { parseNewCar, ValidationError } from "./validate.js";
|
|
5
|
+
|
|
6
|
+
export const carRouter = Router();
|
|
7
|
+
|
|
8
|
+
carRouter.get("/", async (_req, res) => {
|
|
9
|
+
res.json(await carStore.list());
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
carRouter.get("/:id", async (req, res) => {
|
|
13
|
+
const car = await carStore.get(Number(req.params.id));
|
|
14
|
+
if (!car) {
|
|
15
|
+
res.status(404).json({ message: "Car not found." });
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
res.json(car);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
carRouter.post("/", async (req, res) => {
|
|
22
|
+
try {
|
|
23
|
+
const car = await carStore.create(parseNewCar(req.body));
|
|
24
|
+
res.status(201).json(car);
|
|
25
|
+
} catch (error) {
|
|
26
|
+
if (error instanceof ValidationError) {
|
|
27
|
+
res.status(400).json({ message: error.message });
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
throw error;
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
carRouter.put("/:id", async (req, res) => {
|
|
35
|
+
try {
|
|
36
|
+
const car = await carStore.update(Number(req.params.id), parseNewCar(req.body));
|
|
37
|
+
if (!car) {
|
|
38
|
+
res.status(404).json({ message: "Car not found." });
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
res.json(car);
|
|
42
|
+
} catch (error) {
|
|
43
|
+
if (error instanceof ValidationError) {
|
|
44
|
+
res.status(400).json({ message: error.message });
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
throw error;
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
carRouter.delete("/:id", async (req, res) => {
|
|
52
|
+
const removed = await carStore.remove(Number(req.params.id));
|
|
53
|
+
if (!removed) {
|
|
54
|
+
res.status(404).json({ message: "Car not found." });
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
res.status(204).end();
|
|
58
|
+
});
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { DEMO_CARS } from "./demo.js";
|
|
2
|
+
import type { Car, NewCar } from "./types.js";
|
|
3
|
+
|
|
4
|
+
// The data layer behind the CRUD routes. With the `db` toggle on it persists to
|
|
5
|
+
// MySQL (Sequelize via @softeneers/db) and **falls back to an in-memory store if
|
|
6
|
+
// the database is unreachable**, so `npm run dev` always yields a working,
|
|
7
|
+
// pre-seeded demo. With `db` off it is always in-memory.
|
|
8
|
+
export interface CarStore {
|
|
9
|
+
list(): Promise<Car[]>;
|
|
10
|
+
get(id: number): Promise<Car | null>;
|
|
11
|
+
create(input: NewCar): Promise<Car>;
|
|
12
|
+
update(id: number, input: NewCar): Promise<Car | null>;
|
|
13
|
+
remove(id: number): Promise<boolean>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// In-memory backend (the default, and the fallback when no database is reachable).
|
|
17
|
+
function createMemoryStore(seed = true): CarStore {
|
|
18
|
+
let nextId = 1;
|
|
19
|
+
const cars = new Map<number, Car>();
|
|
20
|
+
if (seed) {
|
|
21
|
+
for (const car of DEMO_CARS) {
|
|
22
|
+
const id = nextId++;
|
|
23
|
+
cars.set(id, { id, ...car });
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return {
|
|
27
|
+
async list() {
|
|
28
|
+
return [...cars.values()].sort((a, b) => a.id - b.id);
|
|
29
|
+
},
|
|
30
|
+
async get(id) {
|
|
31
|
+
return cars.get(id) ?? null;
|
|
32
|
+
},
|
|
33
|
+
async create(input) {
|
|
34
|
+
const car: Car = { id: nextId++, ...input };
|
|
35
|
+
cars.set(car.id, car);
|
|
36
|
+
return car;
|
|
37
|
+
},
|
|
38
|
+
async update(id, input) {
|
|
39
|
+
const car = cars.get(id);
|
|
40
|
+
if (!car) return null;
|
|
41
|
+
const next: Car = { ...car, ...input };
|
|
42
|
+
cars.set(id, next);
|
|
43
|
+
return next;
|
|
44
|
+
},
|
|
45
|
+
async remove(id) {
|
|
46
|
+
return cars.delete(id);
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// #if db
|
|
52
|
+
import { assertConnection, DataTypes, Model } from "@softeneers/db";
|
|
53
|
+
|
|
54
|
+
import { sequelize } from "../db.js";
|
|
55
|
+
|
|
56
|
+
class CarModel extends Model {
|
|
57
|
+
declare id: number;
|
|
58
|
+
declare brand: string;
|
|
59
|
+
declare model: string;
|
|
60
|
+
declare year: number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
CarModel.init(
|
|
64
|
+
{
|
|
65
|
+
id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true },
|
|
66
|
+
brand: { type: DataTypes.STRING, allowNull: false },
|
|
67
|
+
model: { type: DataTypes.STRING, allowNull: false },
|
|
68
|
+
year: { type: DataTypes.INTEGER, allowNull: false },
|
|
69
|
+
},
|
|
70
|
+
{ sequelize, modelName: "Car", tableName: "cars" },
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
export { CarModel };
|
|
74
|
+
|
|
75
|
+
const toCar = (m: CarModel): Car => ({ id: m.id, brand: m.brand, model: m.model, year: m.year });
|
|
76
|
+
|
|
77
|
+
function createDbStore(): CarStore {
|
|
78
|
+
return {
|
|
79
|
+
async list() {
|
|
80
|
+
return (await CarModel.findAll({ order: [["id", "ASC"]] })).map(toCar);
|
|
81
|
+
},
|
|
82
|
+
async get(id) {
|
|
83
|
+
const m = await CarModel.findByPk(id);
|
|
84
|
+
return m ? toCar(m) : null;
|
|
85
|
+
},
|
|
86
|
+
async create(input) {
|
|
87
|
+
return toCar(await CarModel.create(input));
|
|
88
|
+
},
|
|
89
|
+
async update(id, input) {
|
|
90
|
+
const m = await CarModel.findByPk(id);
|
|
91
|
+
if (!m) return null;
|
|
92
|
+
await m.update(input);
|
|
93
|
+
return toCar(m);
|
|
94
|
+
},
|
|
95
|
+
async remove(id) {
|
|
96
|
+
const m = await CarModel.findByPk(id);
|
|
97
|
+
if (!m) return false;
|
|
98
|
+
await m.destroy();
|
|
99
|
+
return true;
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Resolve the backend once, lazily, on first use: try MySQL (create tables +
|
|
105
|
+
// seed if empty); on any connection error, fall back to the in-memory store.
|
|
106
|
+
let backend: Promise<CarStore> | null = null;
|
|
107
|
+
function resolveBackend(): Promise<CarStore> {
|
|
108
|
+
if (!backend) {
|
|
109
|
+
backend = (async () => {
|
|
110
|
+
try {
|
|
111
|
+
await assertConnection(sequelize);
|
|
112
|
+
await sequelize.sync();
|
|
113
|
+
const db = createDbStore();
|
|
114
|
+
if ((await db.list()).length === 0) {
|
|
115
|
+
for (const car of DEMO_CARS) await db.create(car);
|
|
116
|
+
}
|
|
117
|
+
console.log("Data store: MySQL");
|
|
118
|
+
return db;
|
|
119
|
+
} catch {
|
|
120
|
+
console.warn(
|
|
121
|
+
"Data store: in-memory — database unreachable. Run `docker compose up -d` (and `npm run db:migrate && npm run db:seed`) for MySQL.",
|
|
122
|
+
);
|
|
123
|
+
return createMemoryStore();
|
|
124
|
+
}
|
|
125
|
+
})();
|
|
126
|
+
}
|
|
127
|
+
return backend;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export const carStore: CarStore = {
|
|
131
|
+
async list() {
|
|
132
|
+
return (await resolveBackend()).list();
|
|
133
|
+
},
|
|
134
|
+
async get(id) {
|
|
135
|
+
return (await resolveBackend()).get(id);
|
|
136
|
+
},
|
|
137
|
+
async create(input) {
|
|
138
|
+
return (await resolveBackend()).create(input);
|
|
139
|
+
},
|
|
140
|
+
async update(id, input) {
|
|
141
|
+
return (await resolveBackend()).update(id, input);
|
|
142
|
+
},
|
|
143
|
+
async remove(id) {
|
|
144
|
+
return (await resolveBackend()).remove(id);
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
// #endif
|
|
148
|
+
// #if !db
|
|
149
|
+
export const carStore: CarStore = createMemoryStore();
|
|
150
|
+
// #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,37 @@
|
|
|
1
|
+
import cors from "cors";
|
|
2
|
+
import express from "express";
|
|
3
|
+
|
|
4
|
+
import { carRouter } from "./cars/routes.js";
|
|
5
|
+
import { env } from "./env.js";
|
|
6
|
+
// #if auth
|
|
7
|
+
import { toNodeHandler } from "@softeneers/auth";
|
|
8
|
+
|
|
9
|
+
import { auth } from "./auth/auth.js";
|
|
10
|
+
// #endif
|
|
11
|
+
|
|
12
|
+
const app = express();
|
|
13
|
+
|
|
14
|
+
// #if auth
|
|
15
|
+
// better-auth handles its own body parsing, so mount it before express.json().
|
|
16
|
+
app.all("/api/auth/*splat", toNodeHandler(auth));
|
|
17
|
+
// #endif
|
|
18
|
+
|
|
19
|
+
app.use(cors({ origin: env.CORS_ORIGIN }));
|
|
20
|
+
app.use(express.json());
|
|
21
|
+
|
|
22
|
+
app.get("/health", (_req, res) => {
|
|
23
|
+
res.json({ ok: true });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
app.use("/api/cars", carRouter);
|
|
27
|
+
|
|
28
|
+
app.use(
|
|
29
|
+
(error: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
|
30
|
+
console.error("Unhandled error:", error);
|
|
31
|
+
res.status(500).json({ message: "Internal server error." });
|
|
32
|
+
},
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
app.listen(env.PORT, () => {
|
|
36
|
+
console.log(`API running on http://localhost:${env.PORT}`);
|
|
37
|
+
});
|
|
@@ -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,20 @@
|
|
|
1
|
+
import "dotenv/config";
|
|
2
|
+
|
|
3
|
+
import { assertConnection } from "@softeneers/db";
|
|
4
|
+
|
|
5
|
+
import { DEMO_CARS } from "../cars/demo.js";
|
|
6
|
+
import { CarModel } from "../cars/store.js";
|
|
7
|
+
import { sequelize } from "../db.js";
|
|
8
|
+
|
|
9
|
+
await assertConnection(sequelize);
|
|
10
|
+
await sequelize.sync();
|
|
11
|
+
|
|
12
|
+
const count = await CarModel.count();
|
|
13
|
+
if (count > 0) {
|
|
14
|
+
console.log(`Skipped seeding — ${count} cars already present.`);
|
|
15
|
+
} else {
|
|
16
|
+
await CarModel.bulkCreate([...DEMO_CARS]);
|
|
17
|
+
console.log(`Seeded ${DEMO_CARS.length} cars.`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
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,13 @@
|
|
|
1
|
+
PORT=4000
|
|
2
|
+
CORS_ORIGIN=http://localhost:3000
|
|
3
|
+
# #if db
|
|
4
|
+
DB_HOST=127.0.0.1
|
|
5
|
+
DB_PORT=3306
|
|
6
|
+
DB_NAME=app_dev
|
|
7
|
+
DB_USER=root
|
|
8
|
+
DB_PASSWORD=
|
|
9
|
+
# #endif
|
|
10
|
+
# #if auth
|
|
11
|
+
AUTH_SECRET=dev-secret-change-me-to-a-long-random-string
|
|
12
|
+
AUTH_BASE_URL=http://localhost:4000
|
|
13
|
+
# #endif
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# {{PROJECT_NAME}}
|
|
2
|
+
|
|
3
|
+
A **Hono + TypeScript** API generated by
|
|
4
|
+
[`create-softeneers-app`](https://www.npmjs.com/package/create-softeneers-app),
|
|
5
|
+
served on Node via `@hono/node-server`, with a full cars CRUD example.
|
|
6
|
+
|
|
7
|
+
## Scripts
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm run dev # watch-run src/index.ts (tsx)
|
|
11
|
+
npm run build # type-check + emit dist/ (tsc)
|
|
12
|
+
npm start # run the built server
|
|
13
|
+
npm run typecheck # tsc --noEmit
|
|
14
|
+
npm test # node:test suite (via tsx)
|
|
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
|
+
## API
|
|
23
|
+
|
|
24
|
+
| Method | Path | Description |
|
|
25
|
+
| ------ | ---------------- | ------------------ |
|
|
26
|
+
| GET | `/health` | Liveness check |
|
|
27
|
+
| GET | `/api/cars` | List cars |
|
|
28
|
+
| GET | `/api/cars/:id` | Get one car |
|
|
29
|
+
| POST | `/api/cars` | Create a car |
|
|
30
|
+
| PUT | `/api/cars/:id` | Update a car |
|
|
31
|
+
| DELETE | `/api/cars/:id` | Delete a car |
|
|
32
|
+
# #if auth
|
|
33
|
+
| GET/POST | `/api/auth/*` | better-auth routes |
|
|
34
|
+
# #endif
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
curl localhost:4000/api/cars
|
|
38
|
+
curl -X POST localhost:4000/api/cars -H 'content-type: application/json' \
|
|
39
|
+
-d '{"brand":"Tesla","model":"Model 3","year":2023}'
|
|
40
|
+
```
|
|
41
|
+
# #if db
|
|
42
|
+
|
|
43
|
+
## Database
|
|
44
|
+
|
|
45
|
+
Persistence is MySQL via Sequelize ([`@softeneers/db`](https://www.npmjs.com/package/@softeneers/db)).
|
|
46
|
+
**`npm run dev` works immediately even without a database** — the store falls back
|
|
47
|
+
to a pre-seeded in-memory one when MySQL is unreachable. To switch on real
|
|
48
|
+
persistence, set `DB_*` in `.env` and start MySQL:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
# #if docker
|
|
52
|
+
docker compose up -d # start MySQL
|
|
53
|
+
# #endif
|
|
54
|
+
# The app auto-creates and seeds tables on first connect; these scripts give
|
|
55
|
+
# you explicit control (e.g. reseeding or a clean reset):
|
|
56
|
+
npm run db:migrate && npm run db:seed
|
|
57
|
+
```
|
|
58
|
+
# #endif
|
|
59
|
+
# #if auth
|
|
60
|
+
|
|
61
|
+
## Authentication
|
|
62
|
+
|
|
63
|
+
Email + password auth via better-auth ([`@softeneers/auth`](https://www.npmjs.com/package/@softeneers/auth)),
|
|
64
|
+
mounted at `/api/auth/*`. Set a strong `AUTH_SECRET` in `.env` before deploying.
|
|
65
|
+
# #endif
|
|
66
|
+
|
|
67
|
+
## Getting started
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
npm install
|
|
71
|
+
npm run dev # a pre-seeded CRUD demo — no other setup needed
|
|
72
|
+
```
|
|
73
|
+
# #if db
|
|
74
|
+
|
|
75
|
+
The app starts immediately on an in-memory demo and uses MySQL automatically once
|
|
76
|
+
it's reachable — see **Database** above to enable persistence.
|
|
77
|
+
# #endif
|
|
@@ -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,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "hono-api",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"description": "Hono + TypeScript API 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
|
+
"db:migrate": "tsx src/scripts/migrate.ts",
|
|
16
|
+
"db:seed": "tsx src/scripts/seed.ts",
|
|
17
|
+
"db:reset": "tsx src/scripts/migrate.ts --reset && tsx src/scripts/seed.ts",
|
|
18
|
+
"test": "node --import tsx --test test/*.test.ts"
|
|
19
|
+
},
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"@hono/node-server": "^1.13.0",
|
|
22
|
+
"@softeneers/auth": "^0.1.0",
|
|
23
|
+
"@softeneers/db": "^0.1.0",
|
|
24
|
+
"@softeneers/env": "^0.1.0",
|
|
25
|
+
"dotenv": "^16.4.5",
|
|
26
|
+
"hono": "^4.6.0",
|
|
27
|
+
"mysql2": "^3.11.0"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@types/node": "^20.0.0",
|
|
31
|
+
"tsx": "^4.19.0",
|
|
32
|
+
"typescript": "^5.6.0"
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"toggles": { "db": false, "auth": false, "docker": false },
|
|
3
|
+
"fragments": {
|
|
4
|
+
"db": {
|
|
5
|
+
"removePaths": ["src/db.ts", "src/scripts", "docker-compose.yml"],
|
|
6
|
+
"removeDeps": ["@softeneers/db", "mysql2"],
|
|
7
|
+
"removeScripts": ["db:migrate", "db:seed", "db:reset"]
|
|
8
|
+
},
|
|
9
|
+
"auth": {
|
|
10
|
+
"removePaths": ["src/auth"],
|
|
11
|
+
"removeDeps": ["@softeneers/auth"]
|
|
12
|
+
},
|
|
13
|
+
"docker": {
|
|
14
|
+
"removePaths": ["docker-compose.yml"]
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { createAuth } from "@softeneers/auth";
|
|
2
|
+
|
|
3
|
+
import { env } from "../env.js";
|
|
4
|
+
|
|
5
|
+
// Email + password authentication via better-auth. With no `database` configured
|
|
6
|
+
// better-auth uses an in-memory store (fine for local dev); point it at your DB
|
|
7
|
+
// for persistence. Routes are mounted at /api/auth/* in src/index.ts.
|
|
8
|
+
export const auth = createAuth({
|
|
9
|
+
secret: env.AUTH_SECRET,
|
|
10
|
+
baseURL: env.AUTH_BASE_URL,
|
|
11
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { NewCar } from "./types.js";
|
|
2
|
+
|
|
3
|
+
// Demo garage inventory seeded on first run so `npm run dev` shows a working
|
|
4
|
+
// CRUD demo immediately — into MySQL when a database is reachable, otherwise
|
|
5
|
+
// into the in-memory store.
|
|
6
|
+
export const DEMO_CARS: NewCar[] = [
|
|
7
|
+
{ brand: "Toyota", model: "Corolla", year: 2021 },
|
|
8
|
+
{ brand: "Tesla", model: "Model 3", year: 2023 },
|
|
9
|
+
{ brand: "Ford", model: "Mustang", year: 1969 },
|
|
10
|
+
];
|