diff --git a/bin/database-sync.ts b/bin/database-sync.ts
index 9c65911..139337b 100644
--- a/bin/database-sync.ts
+++ b/bin/database-sync.ts
@@ -3,30 +3,18 @@ import readline from "node:readline/promises";
import { DatabaseSync } from "node:sqlite";
import fs from "fs-extra";
-// Build the path to the schema SQL file
-const schema = path.join(import.meta.dirname, "../src/database/schema.sql");
-const seeder = path.join(import.meta.dirname, "../src/database/seeder.sql");
-const sqlite = path.join(
- import.meta.dirname,
- "../src/database/data/database.sqlite",
-);
-
-// Setup readline for interactive confirmation.
-const rl = readline.createInterface({
- input: process.stdin,
- output: process.stdout,
-});
-
-// Asks the user for confirmation.
-async function confirm(question: string): Promise {
- const answer = await rl.question(`${question} (y/N) `);
- return answer.toLowerCase() === "y";
-}
+export async function main(
+ argv: string[] = process.argv,
+ rootDirOverride?: string,
+) {
+ const rootDir = rootDirOverride ?? path.join(import.meta.dirname, "..");
-let database: DatabaseSync | null = null;
+ // Build the paths to the schema, seeder and database files
+ const schema = path.join(rootDir, "src/database/schema.sql");
+ const seeder = path.join(rootDir, "src/database/seeder.sql");
+ const sqlite = path.join(rootDir, "src/database/data/database.sqlite");
-async function main() {
- const args = process.argv.slice(2);
+ const args = argv.slice(2);
const useSeeder = args.includes("--use-seeder");
const noInteraction =
@@ -38,67 +26,73 @@ async function main() {
];
if (args.length !== expectedArgs.length) {
- console.error(
+ throw new Error(
"Usage: npm run database:sync [-- --use-seeder] [--no-interaction|-n]",
);
- process.exit(1);
}
console.info(
`This script will drop existing '${path.normalize(sqlite)}' to create a new one.`,
);
- const proceed = async () => {
- if (noInteraction) {
- console.info(
- "Running in non-interactive mode. Proceeding automatically.",
- );
- return true;
- } else {
- return await confirm(
- "Are you sure you want to continue? This action cannot be undone.",
+ if (!noInteraction) {
+ const rl = readline.createInterface({
+ input: process.stdin,
+ output: process.stdout,
+ });
+
+ try {
+ const answer = await rl.question(
+ "Are you sure you want to continue? This action cannot be undone. (y/N) ",
);
- }
- };
- if (!(await proceed())) {
- console.info("\nSync operation cancelled.");
- return;
+ if (answer.toLowerCase() !== "y") {
+ console.info("\nSync operation cancelled.");
+ return;
+ }
+ } finally {
+ rl.close();
+ }
}
// Delete the existing database file if it exists
await fs.remove(sqlite);
+ // Ensure the parent directory exists
+ await fs.ensureDir(path.dirname(sqlite));
+
// Create a new database with the specified name
- database = new DatabaseSync(sqlite);
+ const database = new DatabaseSync(sqlite);
- // Read the SQL statements from the schema file
- const sql = await fs.readFile(schema, "utf8");
+ try {
+ // Read the SQL statements from the schema file
+ const sql = await fs.readFile(schema, "utf8");
- // Execute the SQL statements to update the database schema
- database.exec(sql);
+ // Execute the SQL statements to update the database schema
+ database.exec(sql);
- console.info(
- `\nDatabase '${path.normalize(sqlite)}' in sync with '${path.normalize(schema)}' 🆙`,
- );
+ console.info(
+ `\nDatabase '${path.normalize(sqlite)}' in sync with '${path.normalize(schema)}' 🆙`,
+ );
- if (useSeeder) {
- // Read the SQL statements from the seeder file
- const sql = await fs.readFile(seeder, "utf8");
+ if (useSeeder) {
+ // Read the SQL statements from the seeder file
+ const sql = await fs.readFile(seeder, "utf8");
- // Execute the SQL statements to seed the database
- database.exec(sql);
+ // Execute the SQL statements to seed the database
+ database.exec(sql);
- console.info(`\nSeeded using '${path.normalize(seeder)}' 🌱`);
+ console.info(`\nSeeded using '${path.normalize(seeder)}' 🌱`);
+ }
+ } finally {
+ database.close();
}
}
-main()
- .catch((err) => {
- console.error("An unexpected error occurred:", err);
+/* v8 ignore next 6 */
+if (process.env.NODE_ENV !== "test") {
+ main().catch((err) => {
+ console.error(err instanceof Error ? err.message : err);
process.exit(1);
- })
- .finally(() => {
- rl.close();
- database?.close();
});
+}
diff --git a/bin/make-purge.ts b/bin/make-purge.ts
index db3d40b..3306923 100644
--- a/bin/make-purge.ts
+++ b/bin/make-purge.ts
@@ -3,23 +3,12 @@ import readline from "node:readline/promises";
import fs from "fs-extra";
-// Locate the project root directory (one level up from /bin).
-const rootDir = path.join(import.meta.dirname, "..");
-
-// Setup readline for interactive confirmation.
-const rl = readline.createInterface({
- input: process.stdin,
- output: process.stdout,
-});
-
-// Asks the user for confirmation.
-async function confirm(question: string): Promise {
- const answer = await rl.question(`${question} (y/N) `);
- return answer.toLowerCase() === "y";
-}
+/* ************************************************************************ */
+/* File system helpers */
+/* ************************************************************************ */
-// Removes a path (file or directory).
-async function remove(fileOrDirectoryPath: string) {
+// Removes a path (file or directory), silently ignoring missing files.
+async function remove(rootDir: string, fileOrDirectoryPath: string) {
try {
await fs.remove(path.join(rootDir, fileOrDirectoryPath));
console.info(`- Removed: ${fileOrDirectoryPath}`);
@@ -34,6 +23,7 @@ async function remove(fileOrDirectoryPath: string) {
// Updates a file's content if it exists.
async function updateFile(
+ rootDir: string,
filePath: string,
replacer: (content: string) => string,
) {
@@ -54,98 +44,180 @@ async function updateFile(
}
}
-// --- Purge Logic ---
+/* ************************************************************************ */
+/* Purge logic */
+/* ************************************************************************ */
// Removes all files and code related to the 'item' module.
-async function purgeItems() {
+async function purgeItems(rootDir: string) {
console.info("\nPurging 'item' module...");
+
// Remove item module files and related React components.
- await remove("src/express/modules/item");
- await remove("src/react/components/item");
- await remove("tests/api/items.test.ts");
- await remove("tests/react/item.test.tsx");
+ await remove(rootDir, "src/express/modules/item");
+ await remove(rootDir, "src/react/components/item");
+ await remove(rootDir, "tests/react/item.test.tsx");
- // Remove item routes and imports from Express and React.
- await updateFile("src/express/routes.ts", (content) =>
+ // Remove item routes from Express.
+ await updateFile(rootDir, "src/express/routes.ts", (content) =>
content.replace(`await importAndUse("./modules/item/itemRoutes");\n`, ""),
);
- await updateFile("src/react/routes.tsx", (content) =>
+ // Remove item routes and import from React.
+ await updateFile(rootDir, "src/react/routes.tsx", (content) =>
content
- .replace(`import { itemRoutes } from "./components/item";\n`, "")
+ .replace(`import { itemRoutes } from "./components/item/index";\n`, "")
.replace(` ...itemRoutes,\n`, ""),
);
- // Remove item table and inserts from schema, seeder and types.
- await updateFile("src/database/schema.sql", (content) => {
- const itemTableRegex = /create table item[\s\S]*?;\n\n/m;
+ // Remove item table from schema.
+ await updateFile(rootDir, "src/database/schema.sql", (content) => {
+ const itemTableRegex = /create table item[\s\S]*?;\n\n?/m;
return content.replace(itemTableRegex, "");
});
- await updateFile("src/database/seeder.sql", (content) => {
+ // Remove item inserts from seeder.
+ await updateFile(rootDir, "src/database/seeder.sql", (content) => {
const itemInsertRegex = /insert into item[\s\S]*?;\n/m;
return content.replace(itemInsertRegex, "");
});
- await updateFile("src/types/index.d.ts", (content) =>
- content.replace(/type Item = {[\s\S]*?};\n\n/m, ""),
+ // Remove Item type.
+ await updateFile(rootDir, "src/types/index.d.ts", (content) =>
+ content.replace(/type Item = \{[\s\S]*?\};\n\n?/m, ""),
);
// Remove item link from NavBar.
- await updateFile("src/react/components/NavBar.tsx", (content) =>
- content.replace(` {link("/items", "Items")}\n`, ""),
+ await updateFile(rootDir, "src/react/components/NavBar.tsx", (content) =>
+ content.replace(` {link("/items", "Items")}\n`, ""),
);
}
-// Remove all files and code related to the 'auth' and 'user' modules.
-async function purgeAuth() {
+// Removes all files and code related to the 'auth' and 'user' modules.
+async function purgeAuth(rootDir: string) {
console.info("\nPurging 'auth' and 'user' modules...");
+
// Remove auth and user module files and related React components.
- await remove("src/express/modules/auth");
- await remove("src/express/modules/user");
- await remove("src/react/components/auth");
- await remove("tests/api/auth.test.ts");
- await remove("tests/api/users.test.ts");
- await remove("tests/react/auth.test.tsx");
-
- // Remove auth/user routes and imports from Express.
- await updateFile("src/express/routes.ts", (content) =>
+ await remove(rootDir, "src/express/modules/auth");
+ await remove(rootDir, "src/express/modules/user");
+ await remove(rootDir, "src/react/components/auth");
+ await remove(rootDir, "tests/react/auth.test.tsx");
+
+ // Remove auth/user routes from Express.
+ await updateFile(rootDir, "src/express/routes.ts", (content) =>
content
.replace(`await importAndUse("./modules/auth/authRoutes");\n`, "")
.replace(`await importAndUse("./modules/user/userRoutes");\n`, ""),
);
- // Remove user table and inserts from schema, seeder and types.
- await updateFile("src/database/schema.sql", (content) => {
- const userTableRegex = /create table user[\s\S]*?;\n\n/m;
- return content.replace(userTableRegex, "");
+ // Remove user and magic_link_token tables from schema.
+ await updateFile(rootDir, "src/database/schema.sql", (content) => {
+ const userTableRegex = /create table user[\s\S]*?;\n\n?/m;
+ const magicLinkTableRegex = /create table magic_link_token[\s\S]*?;\n\n?/m;
+ return content.replace(userTableRegex, "").replace(magicLinkTableRegex, "");
});
- await updateFile("src/database/seeder.sql", (content) => {
- const userInsertRegex = /insert into user[\s\S]*?;\n\n/m;
+ // Remove user inserts from seeder.
+ await updateFile(rootDir, "src/database/seeder.sql", (content) => {
+ const userInsertRegex = /insert into user[\s\S]*?;\n\n?/m;
return content.replace(userInsertRegex, "");
});
- await updateFile("src/types/index.d.ts", (content) =>
- content.replace(/type User = {[\s\S]*?};\n\n/m, ""),
+ // Remove User and MagicLinkToken types.
+ await updateFile(rootDir, "src/types/index.d.ts", (content) =>
+ content
+ .replace(/type User = \{[\s\S]*?\};\n\n?/m, "")
+ .replace(/type MagicLinkToken = \{[\s\S]*?\};\n\n?/m, ""),
+ );
+
+ // Remove auth imports, loader, and auth routes from routes.tsx.
+ await updateFile(rootDir, "src/react/routes.tsx", (content) =>
+ content
+ // Remove auth-related imports
+ .replace(`import LogoutForm from "./components/auth/LogoutForm";\n`, "")
+ .replace(`import VerifyPage from "./components/auth/VerifyPage";\n`, "")
+ .replace(
+ `import { AuthProvider } from "./components/auth/AuthContext";\n`,
+ "",
+ )
+ // Simplify RouteObject import (remove useLoaderData)
+ .replace(
+ `import { type RouteObject, useLoaderData } from "react-router";`,
+ `import type { RouteObject } from "react-router";`,
+ )
+ // Remove AuthProvider wrapper and useLoaderData usage
+ .replace(
+ /Component: \(\) => \{[\s\S]*?\},\n/m,
+ `Component: () => {\n return (\n \n \n \n );\n },\n`,
+ )
+ // Remove the loader
+ .replace(/ {4}\/\*\n {6}Root loader:[\s\S]*?\n {4}\},\n/m, "")
+ // Remove logout and verify routes
+ .replace(
+ / {6}\{\n {8}path: "logout",\n {8}element: ,\n {6}\},\n/m,
+ "",
+ )
+ .replace(
+ / {6}\{\n {8}path: "verify",\n {8}element: ,\n {6}\},\n/m,
+ "",
+ ),
+ );
+
+ // Remove auth-related code from Layout.tsx.
+ await updateFile(rootDir, "src/react/components/Layout.tsx", (content) =>
+ content
+ // Remove auth imports
+ .replace(
+ `import { Outlet, useLocation } from "react-router";`,
+ `import { Outlet } from "react-router";`,
+ )
+ .replace(`import { useAuth } from "./auth/AuthContext";\n`, "")
+ .replace(`import MagicLinkForm from "./auth/MagicLinkForm";\n`, "")
+ // Remove auth hooks
+ .replace(` const { check } = useAuth();\n`, "")
+ .replace(` const location = useLocation();\n\n`, "")
+ // Replace conditional rendering with simple Outlet
+ .replace(
+ ` {check() || location.pathname === "/verify" ? (\n \n ) : (\n \n )}`,
+ ` `,
+ ),
);
- // Remove AuthProvider and related imports from Layout.
- await updateFile("src/react/components/Layout.tsx", (content) =>
+ // Remove auth-related code from NavBar.tsx.
+ await updateFile(rootDir, "src/react/components/NavBar.tsx", (content) =>
content
- .replace(`import { AuthProvider } from "./auth/AuthContext";\n`, "")
- .replace(`import AuthForm from "./auth/AuthForm";\n`, "")
- .replace(/([\s\S]*)<\/AuthProvider>/m, "<>$1>"),
+ .replace(`import { useAuth } from "./auth/AuthContext";\n\n`, "")
+ .replace(` const { check } = useAuth();\n`, "")
+ // After purgeItems, only the logout link remains in the auth block.
+ // Remove the whole conditional block.
+ .replace(/ {8}\{check\(\) && \(\n[\s\S]*?\n {8}\)}\n/m, ""),
);
}
-// Entry point: parse arguments, confirm, and run purge.
-async function main() {
- const [, , keepAuth, ...unexpected] = process.argv;
+/* ************************************************************************ */
+/* Entry point */
+/* ************************************************************************ */
- if ((keepAuth && keepAuth !== "--keep-auth") || unexpected.length > 0) {
- console.error("Usage: npm run make:purge [-- --keep-auth]");
- process.exit(1);
+export async function main(
+ argv: string[] = process.argv,
+ rootDirOverride?: string,
+) {
+ const rootDir = rootDirOverride ?? path.join(import.meta.dirname, "..");
+
+ const args = argv.slice(2);
+
+ const keepAuth = args.includes("--keep-auth");
+ const noInteraction =
+ args.includes("--no-interaction") || args.includes("-n");
+
+ const expectedArgs = [
+ ...(keepAuth ? ["--keep-auth"] : []),
+ ...(noInteraction ? ["--no-interaction"] : []),
+ ];
+
+ if (args.length !== expectedArgs.length) {
+ throw new Error(
+ "Usage: npm run make:purge [-- --keep-auth] [--no-interaction|-n]",
+ );
}
console.info(
@@ -160,19 +232,30 @@ async function main() {
console.info("All boilerplate modules (item, user, auth) will be removed.");
}
- const proceed = await confirm(
- "Are you sure you want to continue? This action cannot be undone.",
- );
-
- if (!proceed) {
- console.info("\nPurge operation cancelled.");
- return;
+ if (!noInteraction) {
+ const rl = readline.createInterface({
+ input: process.stdin,
+ output: process.stdout,
+ });
+
+ try {
+ const answer = await rl.question(
+ "Are you sure you want to continue? This action cannot be undone. (y/N) ",
+ );
+
+ if (answer.toLowerCase() !== "y") {
+ console.info("\nPurge operation cancelled.");
+ return;
+ }
+ } finally {
+ rl.close();
+ }
}
- await purgeItems();
+ await purgeItems(rootDir);
if (!keepAuth) {
- await purgeAuth();
+ await purgeAuth(rootDir);
}
console.info("\nPurge complete! ✨");
@@ -181,11 +264,10 @@ async function main() {
);
}
-main()
- .catch((err) => {
- console.error("An unexpected error occurred:", err);
+/* v8 ignore next 6 */
+if (process.env.NODE_ENV !== "test") {
+ main().catch((err) => {
+ console.error(err instanceof Error ? err.message : err);
process.exit(1);
- })
- .finally(() => {
- rl.close();
});
+}
diff --git a/package-lock.json b/package-lock.json
index 15f283a..de24ab7 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "starter",
- "version": "2026.04.26",
+ "version": "2026.04.27",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "starter",
- "version": "2026.04.26",
+ "version": "2026.04.27",
"dependencies": {
"compression": "^1.8.1",
"cookie-parser": "^1.4.7",
@@ -37,7 +37,7 @@
"@vitejs/plugin-react": "^6.0.1",
"@vitest/coverage-v8": "^4.1.5",
"fs-extra": "^11.3.4",
- "jsdom": "^29.0.2",
+ "jsdom": "^29.1.0",
"pluralize": "^8.0.0",
"supertest": "^7.2.2",
"tsx": "^4.21.0",
@@ -64,9 +64,9 @@
}
},
"node_modules/@asamuzakjp/dom-selector": {
- "version": "7.0.10",
- "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.0.10.tgz",
- "integrity": "sha512-KyOb19eytNSELkmdqzZZUXWCU25byIlOld5qVFg0RYdS0T3tt7jeDByxk9hIAC73frclD8GKrHttr0SUjKCCdQ==",
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz",
+ "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2332,13 +2332,13 @@
}
},
"node_modules/entities": {
- "version": "6.0.1",
- "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
- "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
+ "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
- "node": ">=0.12"
+ "node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
@@ -3020,28 +3020,28 @@
"peer": true
},
"node_modules/jsdom": {
- "version": "29.0.2",
- "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.2.tgz",
- "integrity": "sha512-9VnGEBosc/ZpwyOsJBCQ/3I5p7Q5ngOY14a9bf5btenAORmZfDse1ZEheMiWcJ3h81+Fv7HmJFdS0szo/waF2w==",
+ "version": "29.1.0",
+ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.0.tgz",
+ "integrity": "sha512-YNUc7fB9QuvSSQWfrH0xF+TyABkxUwx8sswgIDaCrw4Hol8BghdZDkITtZheRJeMtzWlnTfsM3bBBusRvpO1wg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@asamuzakjp/css-color": "^5.1.5",
- "@asamuzakjp/dom-selector": "^7.0.6",
+ "@asamuzakjp/css-color": "^5.1.11",
+ "@asamuzakjp/dom-selector": "^7.1.1",
"@bramus/specificity": "^2.4.2",
- "@csstools/css-syntax-patches-for-csstree": "^1.1.1",
+ "@csstools/css-syntax-patches-for-csstree": "^1.1.3",
"@exodus/bytes": "^1.15.0",
"css-tree": "^3.2.1",
"data-urls": "^7.0.0",
"decimal.js": "^10.6.0",
"html-encoding-sniffer": "^6.0.0",
"is-potential-custom-element-name": "^1.0.1",
- "lru-cache": "^11.2.7",
- "parse5": "^8.0.0",
+ "lru-cache": "^11.3.5",
+ "parse5": "^8.0.1",
"saxes": "^6.0.0",
"symbol-tree": "^3.2.4",
"tough-cookie": "^6.0.1",
- "undici": "^7.24.5",
+ "undici": "^7.25.0",
"w3c-xmlserializer": "^5.0.0",
"webidl-conversions": "^8.0.1",
"whatwg-mimetype": "^5.0.0",
@@ -3666,13 +3666,13 @@
}
},
"node_modules/parse5": {
- "version": "8.0.0",
- "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz",
- "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==",
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
+ "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "entities": "^6.0.0"
+ "entities": "^8.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
diff --git a/package.json b/package.json
index a73071a..d5defde 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"type": "module",
"name": "starter",
- "version": "2026.04.26",
+ "version": "2026.04.27",
"main": "server.ts",
"scripts": {
"prepare": "git config core.hooksPath .git-hooks || true",
@@ -36,7 +36,7 @@
"@vitejs/plugin-react": "^6.0.1",
"@vitest/coverage-v8": "^4.1.5",
"fs-extra": "^11.3.4",
- "jsdom": "^29.0.2",
+ "jsdom": "^29.1.0",
"pluralize": "^8.0.0",
"supertest": "^7.2.2",
"tsx": "^4.21.0",
diff --git a/server.ts b/server.ts
index 25fbccd..8e40a1b 100644
--- a/server.ts
+++ b/server.ts
@@ -54,6 +54,8 @@ const isProduction = process.env.NODE_ENV === "production";
const port = +(process.env.APP_PORT ?? 5173);
+const indexHtml = readIndexHtml();
+
// Server creation is async because it may initialize Vite in dev mode
createServer().then((server) => {
server.listen(port, () => {
@@ -121,8 +123,6 @@ export async function createServer() {
/* ****************************************************************** */
const getTemplateAndRender = async (url: string) => {
- const indexHtml = readIndexHtml();
-
// Production mode:
// SSR bundle is prebuilt and loaded from dist/
if (maybeVite == null) {
@@ -182,6 +182,10 @@ export async function createServer() {
/* Error handling */
/* ********************************************************************** */
+ /*
+ Error logging middleware:
+ Logs errors for debugging, then passes them to the error response handler.
+ */
const logErrors: ErrorRequestHandler = (err, req, _res, next) => {
console.error(err);
console.error("on req:", req.method, req.path);
@@ -189,7 +193,22 @@ export async function createServer() {
next(err);
};
+ /*
+ Final error handler:
+ Sends a structured JSON response instead of Express's default HTML page.
+ Stack traces are hidden in production to avoid leaking implementation details.
+ */
+ const sendErrors: ErrorRequestHandler = (err, _req, res, _next) => {
+ const status = err.status ?? err.statusCode ?? 500;
+
+ res.status(status).json({
+ message: err.message ?? "Internal Server Error",
+ ...(isProduction ? {} : { stack: err.stack }),
+ });
+ };
+
app.use(logErrors);
+ app.use(sendErrors);
return app;
}
diff --git a/src/react/components/auth/MagicLinkForm.tsx b/src/react/components/auth/MagicLinkForm.tsx
index 40b178e..4e03397 100644
--- a/src/react/components/auth/MagicLinkForm.tsx
+++ b/src/react/components/auth/MagicLinkForm.tsx
@@ -48,7 +48,7 @@ function MagicLinkForm() {
type="email"
name="email"
defaultValue=""
- placeholder="ton.adresse@mail.com"
+ placeholder="your.address@mail.com"
required
/>
diff --git a/src/react/components/auth/VerifyPage.tsx b/src/react/components/auth/VerifyPage.tsx
index 9952bc3..861780d 100644
--- a/src/react/components/auth/VerifyPage.tsx
+++ b/src/react/components/auth/VerifyPage.tsx
@@ -46,7 +46,7 @@ function VerifyPage() {
This login link is no longer valid. It may have expired or already
been used.
- Request a new link
+ Request a new link
);
}
diff --git a/src/react/components/item/ItemForm.tsx b/src/react/components/item/ItemForm.tsx
index d6355c6..4a92664 100644
--- a/src/react/components/item/ItemForm.tsx
+++ b/src/react/components/item/ItemForm.tsx
@@ -17,7 +17,7 @@ import { type PropsWithChildren, useId } from "react";
import { z } from "zod";
const itemSchema = z.object({
- title: z.string().min(1, "Le titre est requis"),
+ title: z.string().min(1, "Title is required"),
});
/*
diff --git a/tests/bin/database-sync.test.ts b/tests/bin/database-sync.test.ts
new file mode 100644
index 0000000..dc4ed40
--- /dev/null
+++ b/tests/bin/database-sync.test.ts
@@ -0,0 +1,131 @@
+import path from "node:path";
+import { DatabaseSync } from "node:sqlite";
+import fs from "fs-extra";
+
+import { main } from "../../bin/database-sync";
+
+const sqlitePath = path.join(
+ import.meta.dirname,
+ "../../src/database/data/database.sqlite",
+);
+
+const runMainWith = async (args: string[]) => {
+ await main(args);
+
+ // Verify the database was created
+ expect(await fs.pathExists(sqlitePath)).toBe(true);
+};
+
+const check = (predicate: (db: DatabaseSync) => void) => {
+ const db = new DatabaseSync(sqlitePath);
+
+ try {
+ predicate(db);
+ } finally {
+ db.close();
+ }
+};
+
+const checkSchema = () => {
+ check((db) => {
+ const tables = db
+ .prepare(
+ "select name from sqlite_schema where type = 'table' and name not like 'sqlite_%'",
+ )
+ .all() as { name: string }[];
+
+ const tableNames = tables.map((t) => t.name);
+
+ expect(tableNames).toContain("user");
+ expect(tableNames).toContain("item");
+ expect(tableNames).toContain("magic_link_token");
+ });
+};
+
+describe("database-sync.ts", () => {
+ let consoleSpy: ReturnType;
+
+ let hadDatabase: boolean;
+ let backupPath: string;
+
+ beforeAll(async () => {
+ // Save a backup of the current database if it exists
+ hadDatabase = await fs.pathExists(sqlitePath);
+
+ if (hadDatabase) {
+ backupPath = `${sqlitePath}.bak`;
+ await fs.copy(sqlitePath, backupPath);
+ }
+ });
+
+ beforeEach(() => {
+ consoleSpy = vi.spyOn(console, "info").mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ consoleSpy.mockRestore();
+ });
+
+ afterAll(async () => {
+ // Restore the original database
+ if (hadDatabase) {
+ await fs.move(backupPath, sqlitePath, { overwrite: true });
+ }
+ });
+
+ it("fails when given unexpected arguments", async () => {
+ await expect(main(["node", "script", "--unknown-flag"])).rejects.toThrow(
+ /Usage/,
+ );
+ });
+
+ it("cancels sync when user answers no interactively", async () => {
+ const readline = await import("node:readline/promises");
+ readline.default.createInterface = vi.fn().mockReturnValue({
+ question: () => "n",
+ close: vi.fn(),
+ });
+
+ await main(["node", "script"]);
+
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringMatching(/cancelled/));
+ });
+
+ it("syncs the database with schema in interactive mode", async () => {
+ const readline = await import("node:readline/promises");
+ readline.default.createInterface = vi.fn().mockReturnValue({
+ question: () => "y",
+ close: vi.fn(),
+ });
+
+ await runMainWith(["node", "script"]);
+
+ checkSchema();
+ });
+
+ it("syncs the database with schema in non-interactive mode", async () => {
+ await runMainWith(["node", "script", "-n"]);
+
+ checkSchema();
+ });
+
+ it("seeds the database when --use-seeder is passed", async () => {
+ await runMainWith(["node", "script", "--use-seeder", "-n"]);
+
+ checkSchema();
+
+ check((db) => {
+ const users = db.prepare("select * from user").all();
+
+ expect(users.length).toBeGreaterThan(0);
+
+ const items = db.prepare("select * from item").all();
+
+ expect(items.length).toBeGreaterThan(0);
+ });
+
+ expect(consoleSpy).toHaveBeenCalledWith(
+ expect.stringMatching(/Seeded using/),
+ );
+ });
+});
diff --git a/tests/bin/make-clone.test.ts b/tests/bin/make-clone.test.ts
index 871dd1c..b1aeedf 100644
--- a/tests/bin/make-clone.test.ts
+++ b/tests/bin/make-clone.test.ts
@@ -19,17 +19,13 @@ describe("make-clone.ts", () => {
});
it("fails when missing arguments", async () => {
- await expect(main(["node", "script"])).rejects.toThrow(
- /Usage: npm run make:clone -- /i,
- );
+ await expect(main(["node", "script"])).rejects.toThrow(/Usage/i);
});
it("fails with invalid JavaScript identifiers for oldName", async () => {
const src = path.join(tmpDir, "src");
const dest = path.join(tmpDir, "dest");
- await fs.mkdir(src);
-
await expect(
main(["node", "script", src, dest, "123cherry", "berry"]),
).rejects.toThrow(/is not a valid identifier/);
@@ -56,6 +52,7 @@ describe("make-clone.ts", () => {
it("fails when source is neither a file nor a directory", async () => {
const src = path.join(tmpDir, "special");
const dest = path.join(tmpDir, "dest");
+
await fs.mkdir(src);
const originalStat = fs.stat;
diff --git a/tests/bin/make-purge.test.ts b/tests/bin/make-purge.test.ts
new file mode 100644
index 0000000..fc68cfe
--- /dev/null
+++ b/tests/bin/make-purge.test.ts
@@ -0,0 +1,407 @@
+import os from "node:os";
+import path from "node:path";
+import fs from "fs-extra";
+
+import { main } from "../../bin/make-purge";
+
+const projectRoot = path.join(import.meta.dirname, "../..");
+
+/**
+ * Creates a project structure by copying the real source files.
+ * This ensures our regex replacements are tested against the actual codebase,
+ * preventing regressions when the codebase changes.
+ */
+async function scaffoldProject(rootDir: string) {
+ await fs.copy(path.join(projectRoot, "src"), path.join(rootDir, "src"), {
+ filter: (srcPath) => !srcPath.includes(path.join("database", "data")),
+ });
+ await fs.copy(path.join(projectRoot, "tests"), path.join(rootDir, "tests"));
+}
+
+const isAlreadyPurged = !fs.existsSync(
+ path.join(projectRoot, "src/express/modules/item"),
+);
+
+describe.skipIf(isAlreadyPurged)("make-purge.ts", () => {
+ let tmpDir: string;
+ let consoleSpy: ReturnType;
+
+ beforeEach(async () => {
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "make-purge-test-"));
+ consoleSpy = vi.spyOn(console, "info").mockImplementation(() => {});
+ });
+
+ afterEach(async () => {
+ await fs.remove(tmpDir);
+ consoleSpy.mockRestore();
+ });
+
+ it("fails when given unexpected arguments", async () => {
+ await expect(main(["node", "script", "--unknown-flag"])).rejects.toThrow(
+ /Usage/,
+ );
+ });
+
+ it("fails when given extra arguments", async () => {
+ await expect(
+ main(["node", "script", "--keep-auth", "--extra"]),
+ ).rejects.toThrow(/Usage/);
+ });
+
+ describe("purge logic", () => {
+ it("runs full purge (items, auth) in non-interactive mode", async () => {
+ await scaffoldProject(tmpDir);
+
+ await main(["node", "script", "-n"], tmpDir);
+
+ // Verify item routes are gone
+ const expressRoutes = await fs.readFile(
+ path.join(tmpDir, "src/express/routes.ts"),
+ "utf8",
+ );
+ expect(expressRoutes).not.toContain("itemRoutes");
+ expect(expressRoutes).not.toContain("authRoutes");
+ expect(expressRoutes).not.toContain("userRoutes");
+
+ // Verify files were removed
+ expect(
+ await fs.pathExists(path.join(tmpDir, "src/express/modules/item")),
+ ).toBe(false);
+ expect(
+ await fs.pathExists(path.join(tmpDir, "src/express/modules/auth")),
+ ).toBe(false);
+ });
+
+ it("runs purge for items only with --keep-auth", async () => {
+ await scaffoldProject(tmpDir);
+
+ await main(["node", "script", "-n", "--keep-auth"], tmpDir);
+
+ // Verify item routes are gone, but auth routes remain
+ const expressRoutes = await fs.readFile(
+ path.join(tmpDir, "src/express/routes.ts"),
+ "utf8",
+ );
+ expect(expressRoutes).not.toContain("itemRoutes");
+ expect(expressRoutes).toContain("authRoutes");
+
+ // Verify files were removed/kept
+ expect(
+ await fs.pathExists(path.join(tmpDir, "src/express/modules/item")),
+ ).toBe(false);
+ expect(
+ await fs.pathExists(path.join(tmpDir, "src/express/modules/auth")),
+ ).toBe(true);
+ });
+
+ it("cancels purge when user answers no interactively", async () => {
+ await scaffoldProject(tmpDir);
+
+ const readline = await import("node:readline/promises");
+ readline.default.createInterface = vi.fn().mockReturnValue({
+ question: () => "n",
+ close: vi.fn(),
+ });
+
+ await main(["node", "script"], tmpDir);
+
+ expect(consoleSpy).toHaveBeenCalledWith(
+ expect.stringMatching(/cancelled/),
+ );
+ expect(
+ await fs.pathExists(path.join(tmpDir, "src/express/modules/item")),
+ ).toBe(true);
+ });
+
+ it("proceeds with purge when user answers yes interactively", async () => {
+ await scaffoldProject(tmpDir);
+
+ const readline = await import("node:readline/promises");
+ readline.default.createInterface = vi.fn().mockReturnValue({
+ question: () => "y",
+ close: vi.fn(),
+ });
+
+ await main(["node", "script", "--keep-auth"], tmpDir);
+
+ expect(consoleSpy).toHaveBeenCalledWith(
+ expect.stringMatching(/Purge complete/),
+ );
+ expect(
+ await fs.pathExists(path.join(tmpDir, "src/express/modules/item")),
+ ).toBe(false);
+ });
+ });
+
+ describe("purgeItems", () => {
+ it("removes item table from schema.sql", async () => {
+ await scaffoldProject(tmpDir);
+
+ const content = await fs.readFile(
+ path.join(tmpDir, "src/database/schema.sql"),
+ "utf8",
+ );
+
+ const itemTableRegex = /create table item[\s\S]*?;\n\n?/m;
+ const result = content.replace(itemTableRegex, "");
+
+ expect(result).not.toContain("create table item");
+ expect(result).toContain("create table user");
+ expect(result).toContain("create table magic_link_token");
+ });
+
+ it("removes item inserts from seeder.sql", async () => {
+ await scaffoldProject(tmpDir);
+
+ const content = await fs.readFile(
+ path.join(tmpDir, "src/database/seeder.sql"),
+ "utf8",
+ );
+
+ const itemInsertRegex = /insert into item[\s\S]*?;\n/m;
+ const result = content.replace(itemInsertRegex, "");
+
+ expect(result).not.toContain("insert into item");
+ expect(result).toContain("insert into user");
+ });
+
+ it("removes Item type from index.d.ts", async () => {
+ await scaffoldProject(tmpDir);
+
+ const content = await fs.readFile(
+ path.join(tmpDir, "src/types/index.d.ts"),
+ "utf8",
+ );
+
+ const result = content.replace(/type Item = \{[\s\S]*?\};\n\n?/m, "");
+
+ expect(result).not.toContain("type Item");
+ expect(result).toContain("type User");
+ expect(result).toContain("type MagicLinkToken");
+ });
+
+ it("removes item link from NavBar.tsx", async () => {
+ await scaffoldProject(tmpDir);
+
+ const content = await fs.readFile(
+ path.join(tmpDir, "src/react/components/NavBar.tsx"),
+ "utf8",
+ );
+
+ const result = content.replace(
+ ` {link("/items", "Items")}\n`,
+ "",
+ );
+
+ expect(result).not.toContain("/items");
+ expect(result).toContain("/logout");
+ expect(result).toContain("Home");
+ });
+
+ it("removes item routes from routes.tsx", async () => {
+ await scaffoldProject(tmpDir);
+
+ const content = await fs.readFile(
+ path.join(tmpDir, "src/react/routes.tsx"),
+ "utf8",
+ );
+
+ const result = content
+ .replace(`import { itemRoutes } from "./components/item/index";\n`, "")
+ .replace(` ...itemRoutes,\n`, "");
+
+ expect(result).not.toContain("itemRoutes");
+ expect(result).toContain("LogoutForm");
+ });
+
+ it("removes item route from express routes.ts", async () => {
+ await scaffoldProject(tmpDir);
+
+ const content = await fs.readFile(
+ path.join(tmpDir, "src/express/routes.ts"),
+ "utf8",
+ );
+
+ const result = content.replace(
+ `await importAndUse("./modules/item/itemRoutes");\n`,
+ "",
+ );
+
+ expect(result).not.toContain("itemRoutes");
+ expect(result).toContain("authRoutes");
+ expect(result).toContain("userRoutes");
+ });
+ });
+
+ describe("purgeAuth", () => {
+ it("removes user and magic_link_token tables from schema.sql", async () => {
+ await scaffoldProject(tmpDir);
+
+ const content = await fs.readFile(
+ path.join(tmpDir, "src/database/schema.sql"),
+ "utf8",
+ );
+
+ const userTableRegex = /create table user[\s\S]*?;\n\n?/m;
+ const magicLinkTableRegex =
+ /create table magic_link_token[\s\S]*?;\n\n?/m;
+ const result = content
+ .replace(userTableRegex, "")
+ .replace(magicLinkTableRegex, "");
+
+ expect(result).not.toContain("create table user");
+ expect(result).not.toContain("create table magic_link_token");
+ expect(result).toContain("create table item");
+ });
+
+ it("removes user inserts from seeder.sql", async () => {
+ await scaffoldProject(tmpDir);
+
+ const content = await fs.readFile(
+ path.join(tmpDir, "src/database/seeder.sql"),
+ "utf8",
+ );
+
+ const userInsertRegex = /insert into user[\s\S]*?;\n\n?/m;
+ const result = content.replace(userInsertRegex, "");
+
+ expect(result).not.toContain("insert into user");
+ expect(result).toContain("insert into item");
+ });
+
+ it("removes User and MagicLinkToken types from index.d.ts", async () => {
+ await scaffoldProject(tmpDir);
+
+ const content = await fs.readFile(
+ path.join(tmpDir, "src/types/index.d.ts"),
+ "utf8",
+ );
+
+ const result = content
+ .replace(/type User = \{[\s\S]*?\};\n\n?/m, "")
+ .replace(/type MagicLinkToken = \{[\s\S]*?\};\n\n?/m, "");
+
+ expect(result).not.toContain("type User");
+ expect(result).not.toContain("type MagicLinkToken");
+ expect(result).toContain("type Item");
+ });
+
+ it("removes auth imports and loader from routes.tsx", async () => {
+ await scaffoldProject(tmpDir);
+
+ const content = await fs.readFile(
+ path.join(tmpDir, "src/react/routes.tsx"),
+ "utf8",
+ );
+
+ const result = content
+ .replace(`import LogoutForm from "./components/auth/LogoutForm";\n`, "")
+ .replace(`import VerifyPage from "./components/auth/VerifyPage";\n`, "")
+ .replace(
+ `import { AuthProvider } from "./components/auth/AuthContext";\n`,
+ "",
+ )
+ .replace(
+ `import { type RouteObject, useLoaderData } from "react-router";`,
+ `import type { RouteObject } from "react-router";`,
+ )
+ .replace(
+ /Component: \(\) => \{[\s\S]*?\},\n/m,
+ `Component: () => {\n return (\n \n \n \n );\n },\n`,
+ )
+ .replace(/ {4}\/\*\n {6}Root loader:[\s\S]*?\n {4}\},\n/m, "")
+ .replace(
+ / {6}\{\n {8}path: "logout",\n {8}element: ,\n {6}\},\n/m,
+ "",
+ )
+ .replace(
+ / {6}\{\n {8}path: "verify",\n {8}element: ,\n {6}\},\n/m,
+ "",
+ );
+
+ expect(result).not.toContain("LogoutForm");
+ expect(result).not.toContain("VerifyPage");
+ expect(result).not.toContain("AuthProvider");
+ expect(result).not.toContain("useLoaderData");
+ expect(result).not.toContain("Root loader");
+ expect(result).not.toContain('path: "logout"');
+ expect(result).not.toContain('path: "verify"');
+ expect(result).toContain("DataRefreshProvider");
+ expect(result).toContain("Layout");
+ });
+
+ it("removes auth code from Layout.tsx", async () => {
+ await scaffoldProject(tmpDir);
+
+ const content = await fs.readFile(
+ path.join(tmpDir, "src/react/components/Layout.tsx"),
+ "utf8",
+ );
+
+ const result = content
+ .replace(
+ `import { Outlet, useLocation } from "react-router";`,
+ `import { Outlet } from "react-router";`,
+ )
+ .replace(`import { useAuth } from "./auth/AuthContext";\n`, "")
+ .replace(`import MagicLinkForm from "./auth/MagicLinkForm";\n`, "")
+ .replace(` const { check } = useAuth();\n`, "")
+ .replace(` const location = useLocation();\n\n`, "")
+ .replace(
+ ` {check() || location.pathname === "/verify" ? (\n \n ) : (\n \n )}`,
+ ` `,
+ );
+
+ expect(result).not.toContain("useAuth");
+ expect(result).not.toContain("MagicLinkForm");
+ expect(result).not.toContain("useLocation");
+ expect(result).not.toContain("check()");
+ expect(result).toContain("");
+ expect(result).toContain("NavBar");
+ });
+
+ it("removes auth code from NavBar.tsx (after purgeItems)", async () => {
+ await scaffoldProject(tmpDir);
+
+ const content = await fs.readFile(
+ path.join(tmpDir, "src/react/components/NavBar.tsx"),
+ "utf8",
+ );
+
+ // First simulate purgeItems removing the items link
+ const afterItems = content.replace(
+ ` {link("/items", "Items")}\n`,
+ "",
+ );
+
+ // Then simulate purgeAuth
+ const result = afterItems
+ .replace(`import { useAuth } from "./auth/AuthContext";\n\n`, "")
+ .replace(` const { check } = useAuth();\n`, "")
+ .replace(/ {8}\{check\(\) && \(\n[\s\S]*?\n {8}\)}\n/m, "");
+
+ expect(result).not.toContain("useAuth");
+ expect(result).not.toContain("check()");
+ expect(result).not.toContain("/items");
+ expect(result).not.toContain("/logout");
+ expect(result).toContain("Home");
+ });
+
+ it("removes auth/user routes from express routes.ts", async () => {
+ await scaffoldProject(tmpDir);
+
+ const content = await fs.readFile(
+ path.join(tmpDir, "src/express/routes.ts"),
+ "utf8",
+ );
+
+ const result = content
+ .replace(`await importAndUse("./modules/auth/authRoutes");\n`, "")
+ .replace(`await importAndUse("./modules/user/userRoutes");\n`, "");
+
+ expect(result).not.toContain("authRoutes");
+ expect(result).not.toContain("userRoutes");
+ expect(result).toContain("itemRoutes");
+ });
+ });
+});