gazan-init 0.1.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/LICENSE +21 -0
- package/README.md +722 -0
- package/bin/gazan.js +17 -0
- package/package.json +54 -0
- package/src/cli/commands/init.js +126 -0
- package/src/cli/index.js +22 -0
- package/src/config/aliases.js +130 -0
- package/src/config/normalize.js +65 -0
- package/src/generators/aliasResolver.js +68 -0
- package/src/generators/aliasRuntime.js +79 -0
- package/src/generators/database/mongoNative.js +45 -0
- package/src/generators/database/mongoose.js +32 -0
- package/src/generators/database/prisma.js +61 -0
- package/src/generators/engine.js +88 -0
- package/src/generators/entity/sensitiveFields.js +12 -0
- package/src/generators/entity/toApp.js +325 -0
- package/src/generators/entity/toMongoose.js +180 -0
- package/src/generators/entity/toPrisma.js +233 -0
- package/src/generators/entity/toZod.js +70 -0
- package/src/generators/env.js +123 -0
- package/src/generators/errors.js +67 -0
- package/src/generators/features/auth.js +163 -0
- package/src/generators/features/bullmq.js +96 -0
- package/src/generators/features/redis.js +34 -0
- package/src/generators/features/socket.js +45 -0
- package/src/generators/gitignore.js +19 -0
- package/src/generators/index.js +317 -0
- package/src/generators/middlewares.js +167 -0
- package/src/generators/packageJson.js +107 -0
- package/src/generators/paths.js +45 -0
- package/src/generators/project.js +189 -0
- package/src/generators/readme.js +177 -0
- package/src/generators/shutdown.js +113 -0
- package/src/generators/stripSensitiveFieldsHelper.js +50 -0
- package/src/generators/syntax.js +65 -0
- package/src/generators/tsconfig.js +32 -0
- package/src/parser/entity/errors.js +21 -0
- package/src/parser/entity/parse.js +417 -0
- package/src/parser/entity/schema.js +104 -0
- package/src/prompts/index.js +186 -0
- package/src/utils/fsSafety.js +17 -0
- package/src/utils/strings.js +110 -0
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { pluralize } = require("../../utils/strings");
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Normalized entity model -> Prisma schema string.
|
|
7
|
+
* entity.json -> Entity Parser -> Normalized Entity Model -> (this) Database Generator
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const PRISMA_TYPE_MAP = {
|
|
11
|
+
string: "String",
|
|
12
|
+
text: "String",
|
|
13
|
+
number: "Float",
|
|
14
|
+
integer: "Int",
|
|
15
|
+
float: "Float",
|
|
16
|
+
boolean: "Boolean",
|
|
17
|
+
date: "DateTime",
|
|
18
|
+
datetime: "DateTime",
|
|
19
|
+
uuid: "String",
|
|
20
|
+
json: "Json",
|
|
21
|
+
enum: null, // resolved to the generated enum name
|
|
22
|
+
decimal: "Decimal",
|
|
23
|
+
bigint: "BigInt",
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
function fieldEnumName(modelName, fieldName) {
|
|
27
|
+
return `${modelName}${fieldName.charAt(0).toUpperCase()}${fieldName.slice(1)}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function buildPrismaField(model, field) {
|
|
31
|
+
const parts = [field.name];
|
|
32
|
+
const prismaType = field.type === "enum" ? fieldEnumName(model.pascalName, field.name) : PRISMA_TYPE_MAP[field.type];
|
|
33
|
+
let typeStr = prismaType;
|
|
34
|
+
// Primary keys are always non-optional in Prisma regardless of the entity.json 'required' flag.
|
|
35
|
+
if (!field.primaryKey && (!field.required || field.nullable)) {
|
|
36
|
+
typeStr += "?";
|
|
37
|
+
}
|
|
38
|
+
parts.push(typeStr);
|
|
39
|
+
|
|
40
|
+
const attrs = [];
|
|
41
|
+
if (field.primaryKey) {
|
|
42
|
+
attrs.push("@id");
|
|
43
|
+
}
|
|
44
|
+
if (field.type === "uuid" && field.default === "uuid") {
|
|
45
|
+
attrs.push("@default(uuid())");
|
|
46
|
+
} else if (field.autoIncrement) {
|
|
47
|
+
attrs.push("@default(autoincrement())");
|
|
48
|
+
} else if ((field.type === "date" || field.type === "datetime") && field.default === "now") {
|
|
49
|
+
attrs.push("@default(now())");
|
|
50
|
+
} else if (field.default !== undefined && field.default !== null && field.default !== "uuid") {
|
|
51
|
+
attrs.push(`@default(${formatDefault(field)})`);
|
|
52
|
+
} else if (field.type === "datetime" && field.name.toLowerCase() === "createdat" && !field.default) {
|
|
53
|
+
attrs.push("@default(now())");
|
|
54
|
+
}
|
|
55
|
+
if (field.unique) attrs.push("@unique");
|
|
56
|
+
if (field.length && (field.type === "string" || field.type === "text")) {
|
|
57
|
+
attrs.push(`@db.VarChar(${field.length})`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (attrs.length > 0) parts.push(attrs.join(" "));
|
|
61
|
+
return parts.join(" ");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function formatDefault(field) {
|
|
65
|
+
if (field.type === "boolean") return String(Boolean(field.default));
|
|
66
|
+
if (["number", "integer", "float", "decimal", "bigint"].includes(field.type)) return String(field.default);
|
|
67
|
+
if (field.type === "enum") return String(field.default);
|
|
68
|
+
return JSON.stringify(field.default);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function buildEnums(model) {
|
|
72
|
+
return model.fields
|
|
73
|
+
.filter((f) => f.type === "enum")
|
|
74
|
+
.map((f) => {
|
|
75
|
+
const enumName = fieldEnumName(model.pascalName, f.name);
|
|
76
|
+
const values = f.values.join("\n ");
|
|
77
|
+
return `enum ${enumName} {\n ${values}\n}`;
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Deterministic Prisma @relation name for the FK identified by (modelHoldingForeignKey, foreignKey).
|
|
83
|
+
* Both the owning (belongsTo) side and the reverse (hasMany/hasOne/auto) side derive the same
|
|
84
|
+
* string independently from these two inputs, so they always pair up — including self-relations
|
|
85
|
+
* and multiple distinct relations between the same two models, which Prisma would otherwise
|
|
86
|
+
* reject as ambiguous.
|
|
87
|
+
*/
|
|
88
|
+
function relationKey(modelHoldingForeignKey, foreignKey) {
|
|
89
|
+
return `${modelHoldingForeignKey.pascalName}_${foreignKey}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Builds a lookup of every hasMany/hasOne relation explicitly declared anywhere in the schema,
|
|
94
|
+
* keyed by the (owner model holding the FK, foreignKey) pair it answers. Used so we never
|
|
95
|
+
* auto-generate a reverse field that duplicates one the user already wrote by hand.
|
|
96
|
+
*/
|
|
97
|
+
function indexExplicitReverses(allModels) {
|
|
98
|
+
const map = new Map();
|
|
99
|
+
for (const declaringModel of allModels) {
|
|
100
|
+
for (const relation of declaringModel.relations) {
|
|
101
|
+
if (relation.type !== "hasMany" && relation.type !== "hasOne") continue;
|
|
102
|
+
const owner = allModels.find((m) => m.name === relation.model);
|
|
103
|
+
if (!owner || !relation.foreignKey) continue;
|
|
104
|
+
map.set(relationKey(owner, relation.foreignKey), {
|
|
105
|
+
declaringModelName: declaringModel.name,
|
|
106
|
+
relationName: relation.name,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return map;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Counts, per (owner model, target model) pair, how many auto-derived (non-explicit) belongsTo
|
|
115
|
+
* relations need a reverse field — so that when there's more than one (e.g. Post.author and
|
|
116
|
+
* Post.editor both pointing at User), every reverse field is disambiguated, not just the 2nd+.
|
|
117
|
+
*/
|
|
118
|
+
function countAutoReversePairs(allModels, explicitReverses) {
|
|
119
|
+
const counts = new Map();
|
|
120
|
+
for (const owner of allModels) {
|
|
121
|
+
for (const relation of owner.relations) {
|
|
122
|
+
if (relation.type !== "belongsTo") continue;
|
|
123
|
+
const target = allModels.find((m) => m.name === relation.model);
|
|
124
|
+
if (!target) continue;
|
|
125
|
+
const key = relationKey(owner, relation.foreignKey);
|
|
126
|
+
if (explicitReverses.has(key)) continue;
|
|
127
|
+
const pairId = `${owner.name}->${target.name}`;
|
|
128
|
+
counts.set(pairId, (counts.get(pairId) || 0) + 1);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return counts;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Fields owned by `model` for its own declared relations (belongsTo/hasMany/hasOne/belongsToMany),
|
|
136
|
+
* plus any auto-derived reverse array fields this model needs to receive because some other model
|
|
137
|
+
* declared a belongsTo pointing at it without an explicit matching hasMany/hasOne.
|
|
138
|
+
*/
|
|
139
|
+
function buildRelationFields(model, allModels, explicitReverses, pairTotalCounts) {
|
|
140
|
+
const lines = [];
|
|
141
|
+
|
|
142
|
+
for (const relation of model.relations) {
|
|
143
|
+
const target = allModels.find((m) => m.name === relation.model);
|
|
144
|
+
if (!target) continue;
|
|
145
|
+
|
|
146
|
+
if (relation.type === "belongsTo") {
|
|
147
|
+
const key = relationKey(model, relation.foreignKey);
|
|
148
|
+
// The relation field's optionality must mirror the FK scalar's — Prisma rejects a required
|
|
149
|
+
// relation object backed by a nullable foreign key (and vice versa).
|
|
150
|
+
const fkField = model.fields.find((f) => f.name === relation.foreignKey);
|
|
151
|
+
const optional = !fkField || !fkField.required || fkField.nullable ? "?" : "";
|
|
152
|
+
lines.push(
|
|
153
|
+
` ${relation.name} ${target.pascalName}${optional} @relation("${key}", fields: [${relation.foreignKey}], references: [${target.primaryKey.name}])`
|
|
154
|
+
);
|
|
155
|
+
} else if (relation.type === "hasMany") {
|
|
156
|
+
const key = relationKey(target, relation.foreignKey);
|
|
157
|
+
lines.push(` ${relation.name} ${target.pascalName}[] @relation("${key}")`);
|
|
158
|
+
} else if (relation.type === "hasOne") {
|
|
159
|
+
const key = relationKey(target, relation.foreignKey);
|
|
160
|
+
lines.push(` ${relation.name} ${target.pascalName}? @relation("${key}")`);
|
|
161
|
+
} else if (relation.type === "belongsToMany") {
|
|
162
|
+
lines.push(` ${relation.name} ${target.pascalName}[] @relation("${relation.through}")`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Auto-derive the reverse side for every belongsTo (anywhere in the schema, including on this
|
|
167
|
+
// model itself for self-relations) that points at `model` and has no explicit hasMany/hasOne.
|
|
168
|
+
for (const owner of allModels) {
|
|
169
|
+
for (const relation of owner.relations) {
|
|
170
|
+
if (relation.type !== "belongsTo" || relation.model !== model.name) continue;
|
|
171
|
+
|
|
172
|
+
const key = relationKey(owner, relation.foreignKey);
|
|
173
|
+
if (explicitReverses.has(key)) continue; // user already declared this side by hand
|
|
174
|
+
|
|
175
|
+
const pairId = `${owner.name}->${model.name}`;
|
|
176
|
+
const base = pluralize(owner.camelName);
|
|
177
|
+
const needsDisambiguation = (pairTotalCounts.get(pairId) || 0) > 1;
|
|
178
|
+
const fieldName = needsDisambiguation ? `${base}As${relation.pascalName}` : base;
|
|
179
|
+
|
|
180
|
+
lines.push(` ${fieldName} ${owner.pascalName}[] @relation("${key}")`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return lines;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function generatePrismaSchema(entityModel, config) {
|
|
188
|
+
const provider = "postgresql";
|
|
189
|
+
const header = [
|
|
190
|
+
`generator client {`,
|
|
191
|
+
` provider = "prisma-client-js"`,
|
|
192
|
+
`}`,
|
|
193
|
+
``,
|
|
194
|
+
`datasource db {`,
|
|
195
|
+
` provider = "${provider}"`,
|
|
196
|
+
` url = env("DATABASE_URL")`,
|
|
197
|
+
`}`,
|
|
198
|
+
].join("\n");
|
|
199
|
+
|
|
200
|
+
const blocks = [];
|
|
201
|
+
const enumBlocks = [];
|
|
202
|
+
const explicitReverses = indexExplicitReverses(entityModel.models);
|
|
203
|
+
const pairTotalCounts = countAutoReversePairs(entityModel.models, explicitReverses);
|
|
204
|
+
|
|
205
|
+
for (const model of entityModel.models) {
|
|
206
|
+
const lines = [`model ${model.pascalName} {`];
|
|
207
|
+
for (const field of model.fields) {
|
|
208
|
+
lines.push(` ${buildPrismaField(model, field)}`);
|
|
209
|
+
}
|
|
210
|
+
const relationLines = buildRelationFields(model, entityModel.models, explicitReverses, pairTotalCounts);
|
|
211
|
+
for (const line of relationLines) lines.push(line);
|
|
212
|
+
|
|
213
|
+
if (model.timestamps) {
|
|
214
|
+
lines.push(` createdAt DateTime @default(now())`);
|
|
215
|
+
lines.push(` updatedAt DateTime @updatedAt`);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const indexedFields = model.fields.filter((f) => f.index && !f.unique && !f.primaryKey);
|
|
219
|
+
for (const field of indexedFields) {
|
|
220
|
+
lines.push(`\n @@index([${field.name}])`);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
lines.push(`\n @@map("${model.tableName}")`);
|
|
224
|
+
lines.push(`}`);
|
|
225
|
+
blocks.push(lines.join("\n"));
|
|
226
|
+
|
|
227
|
+
enumBlocks.push(...buildEnums(model));
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return [header, ...enumBlocks, ...blocks].join("\n\n") + "\n";
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
module.exports = { generatePrismaSchema };
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { isEsm } = require("../syntax");
|
|
4
|
+
|
|
5
|
+
const ZOD_BASE = {
|
|
6
|
+
string: "z.string()",
|
|
7
|
+
text: "z.string()",
|
|
8
|
+
number: "z.number()",
|
|
9
|
+
integer: "z.number().int()",
|
|
10
|
+
float: "z.number()",
|
|
11
|
+
boolean: "z.boolean()",
|
|
12
|
+
date: "z.coerce.date()",
|
|
13
|
+
datetime: "z.coerce.date()",
|
|
14
|
+
uuid: "z.string().uuid()",
|
|
15
|
+
json: "z.record(z.string(), z.any())",
|
|
16
|
+
decimal: "z.number()",
|
|
17
|
+
bigint: "z.number().int()",
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function buildFieldZod(field) {
|
|
21
|
+
if (field.type === "enum") {
|
|
22
|
+
let expr = `z.enum(${JSON.stringify(field.values)})`;
|
|
23
|
+
if (!field.required) expr += ".optional()";
|
|
24
|
+
if (field.nullable) expr += ".nullable()";
|
|
25
|
+
return expr;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
let expr = ZOD_BASE[field.type];
|
|
29
|
+
if (field.type === "string" || field.type === "text") {
|
|
30
|
+
if (field.length) expr += `.max(${field.length})`;
|
|
31
|
+
if (field.min !== undefined) expr += `.min(${field.min})`;
|
|
32
|
+
} else if (["number", "integer", "float", "decimal", "bigint"].includes(field.type)) {
|
|
33
|
+
if (field.min !== undefined) expr += `.min(${field.min})`;
|
|
34
|
+
if (field.max !== undefined) expr += `.max(${field.max})`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (!field.required) expr += ".optional()";
|
|
38
|
+
if (field.nullable) expr += ".nullable()";
|
|
39
|
+
return expr;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** entity model -> Zod create/update validators. Skips the primary key and timestamp fields on write payloads. */
|
|
43
|
+
function generateEntityValidator(model, config) {
|
|
44
|
+
const esm = isEsm(config);
|
|
45
|
+
const zodImport = esm ? `import { z } from "zod";` : `const { z } = require("zod");`;
|
|
46
|
+
|
|
47
|
+
const writableFields = model.fields.filter((f) => !(f.primaryKey && f.synthetic));
|
|
48
|
+
|
|
49
|
+
const createLines = writableFields.map((f) => ` ${f.camelName}: ${buildFieldZod(f)},`).join("\n");
|
|
50
|
+
|
|
51
|
+
const createSchemaName = `create${model.pascalName}Schema`;
|
|
52
|
+
const updateSchemaName = `update${model.pascalName}Schema`;
|
|
53
|
+
|
|
54
|
+
const body = `${zodImport}
|
|
55
|
+
|
|
56
|
+
const ${createSchemaName} = z.object({
|
|
57
|
+
${createLines}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const ${updateSchemaName} = ${createSchemaName}.partial();
|
|
61
|
+
`;
|
|
62
|
+
|
|
63
|
+
const footer = esm
|
|
64
|
+
? `\nexport { ${createSchemaName}, ${updateSchemaName} };\n`
|
|
65
|
+
: `\nmodule.exports = { ${createSchemaName}, ${updateSchemaName} };\n`;
|
|
66
|
+
|
|
67
|
+
return body + footer;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
module.exports = { generateEntityValidator };
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { isEsm } = require("./syntax");
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Computes which env vars this project actually needs, based on the
|
|
7
|
+
* normalized config. Nothing here is generated for disabled features.
|
|
8
|
+
*/
|
|
9
|
+
function buildEnvSpec(config) {
|
|
10
|
+
const vars = [
|
|
11
|
+
{ name: "NODE_ENV", zod: `z.enum(["development", "test", "production"]).default("development")`, example: "development" },
|
|
12
|
+
{ name: "PORT", zod: `z.coerce.number().int().positive().default(3000)`, example: "3000" },
|
|
13
|
+
{ name: "CORS_ORIGIN", zod: `z.string().default("*")`, example: "*" },
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
if (config.database.type === "postgresql") {
|
|
17
|
+
vars.push({
|
|
18
|
+
name: "DATABASE_URL",
|
|
19
|
+
zod: `z.string().url()`,
|
|
20
|
+
example: "postgresql://user:password@localhost:5432/mydb",
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (config.database.type === "mongodb") {
|
|
25
|
+
vars.push({
|
|
26
|
+
name: "MONGODB_URI",
|
|
27
|
+
zod: `z.string().min(1)`,
|
|
28
|
+
example: "mongodb://localhost:27017/mydb",
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (config.redis) {
|
|
33
|
+
vars.push({
|
|
34
|
+
name: "REDIS_URL",
|
|
35
|
+
zod: `z.string().min(1).default("redis://localhost:6379")`,
|
|
36
|
+
example: "redis://localhost:6379",
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// helpers/jwt.js always emits sign/verifyAccessToken (using JWT_SECRET/JWT_EXPIRES_IN) whenever
|
|
41
|
+
// it's generated at all — which happens for EITHER 'jwt' or 'refresh-token' — so these must be
|
|
42
|
+
// required in both cases, not just when 'jwt' itself is selected.
|
|
43
|
+
if (
|
|
44
|
+
config.authentication.enabled &&
|
|
45
|
+
(config.authentication.methods.includes("jwt") || config.authentication.methods.includes("refresh-token"))
|
|
46
|
+
) {
|
|
47
|
+
vars.push({ name: "JWT_SECRET", zod: `z.string().min(10)`, example: "replace-with-a-long-random-secret" });
|
|
48
|
+
vars.push({ name: "JWT_EXPIRES_IN", zod: `z.string().default("15m")`, example: "15m" });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (config.authentication.enabled && config.authentication.methods.includes("refresh-token")) {
|
|
52
|
+
vars.push({ name: "JWT_REFRESH_SECRET", zod: `z.string().min(10)`, example: "replace-with-a-long-random-secret" });
|
|
53
|
+
vars.push({ name: "JWT_REFRESH_EXPIRES_IN", zod: `z.string().default("7d")`, example: "7d" });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (config.authentication.enabled && config.authentication.methods.includes("oauth")) {
|
|
57
|
+
vars.push({ name: "OAUTH_CLIENT_ID", zod: `z.string().min(1)`, example: "your-oauth-client-id" });
|
|
58
|
+
vars.push({ name: "OAUTH_CLIENT_SECRET", zod: `z.string().min(1)`, example: "your-oauth-client-secret" });
|
|
59
|
+
vars.push({ name: "OAUTH_CALLBACK_URL", zod: `z.string().url()`, example: "http://localhost:3000/auth/oauth/callback" });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return vars;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function generateValidateEnvFile(config) {
|
|
66
|
+
const vars = buildEnvSpec(config);
|
|
67
|
+
const esm = isEsm(config);
|
|
68
|
+
const isTs = config.language === "ts";
|
|
69
|
+
|
|
70
|
+
const zodImport = esm ? `import { z } from "zod";` : `const { z } = require("zod");`;
|
|
71
|
+
|
|
72
|
+
const schemaFields = vars.map((v) => ` ${v.name}: ${v.zod},`).join("\n");
|
|
73
|
+
|
|
74
|
+
const fnBody = `${zodImport}
|
|
75
|
+
|
|
76
|
+
const envSchema = z.object({
|
|
77
|
+
${schemaFields}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
${isTs ? "export type Env = z.infer<typeof envSchema>;\n\n" : ""}function validateEnv(source${isTs ? ": NodeJS.ProcessEnv" : ""} = process.env)${isTs ? ": Env" : ""} {
|
|
81
|
+
const result = envSchema.safeParse(source);
|
|
82
|
+
|
|
83
|
+
if (!result.success) {
|
|
84
|
+
const messages = result.error.issues
|
|
85
|
+
.map((issue) => \` \${issue.path.join(".")}: \${issue.message}\`)
|
|
86
|
+
.join("\\n");
|
|
87
|
+
throw new Error(\`Invalid environment configuration:\\n\${messages}\`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return result.data;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
${esm ? "export { validateEnv };" : "module.exports = { validateEnv };"}
|
|
94
|
+
`;
|
|
95
|
+
|
|
96
|
+
return fnBody;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function generateEnvHelperFile(config, validateEnvImportPath) {
|
|
100
|
+
const esm = isEsm(config);
|
|
101
|
+
const dotenvImport = esm ? `import dotenv from "dotenv";` : `const dotenv = require("dotenv");`;
|
|
102
|
+
const validateImport = esm
|
|
103
|
+
? `import { validateEnv } from "${validateEnvImportPath}";`
|
|
104
|
+
: `const { validateEnv } = require("${validateEnvImportPath}");`;
|
|
105
|
+
|
|
106
|
+
return `${dotenvImport}
|
|
107
|
+
${validateImport}
|
|
108
|
+
|
|
109
|
+
dotenv.config();
|
|
110
|
+
|
|
111
|
+
const env = validateEnv(process.env);
|
|
112
|
+
|
|
113
|
+
${esm ? "export { env };\nexport default env;" : "module.exports = { env };\nmodule.exports.default = env;"}
|
|
114
|
+
`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function buildEnvFileContents(config, { forExample }) {
|
|
118
|
+
const vars = buildEnvSpec(config);
|
|
119
|
+
const lines = vars.map((v) => `${v.name}=${forExample ? v.example : v.example}`);
|
|
120
|
+
return lines.join("\n") + "\n";
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
module.exports = { buildEnvSpec, generateValidateEnvFile, generateEnvHelperFile, buildEnvFileContents };
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { isEsm } = require("./syntax");
|
|
4
|
+
|
|
5
|
+
const NAMES = [
|
|
6
|
+
"HTTPException",
|
|
7
|
+
"BadRequestException",
|
|
8
|
+
"UnauthorizedException",
|
|
9
|
+
"ForbiddenException",
|
|
10
|
+
"NotFoundException",
|
|
11
|
+
"ConflictException",
|
|
12
|
+
"UnprocessableEntityException",
|
|
13
|
+
"InternalServerErrorException",
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
function generateErrorsFile(config) {
|
|
17
|
+
const isTs = config.language === "ts";
|
|
18
|
+
const esm = isEsm(config);
|
|
19
|
+
const kw = esm ? "export class" : "class";
|
|
20
|
+
|
|
21
|
+
const typedCtorArgs = isTs
|
|
22
|
+
? {
|
|
23
|
+
base: "statusCode: number, message: string, details: unknown = null",
|
|
24
|
+
sub: "message = \"__DEFAULT__\", details: unknown = null",
|
|
25
|
+
}
|
|
26
|
+
: {
|
|
27
|
+
base: "statusCode, message, details = null",
|
|
28
|
+
sub: "message = \"__DEFAULT__\", details = null",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const fields = isTs ? " public statusCode: number;\n public details: unknown;\n\n" : "";
|
|
32
|
+
|
|
33
|
+
const base = `${kw} HTTPException extends Error {
|
|
34
|
+
${fields} constructor(${typedCtorArgs.base}) {
|
|
35
|
+
super(message);
|
|
36
|
+
this.name = "HTTPException";
|
|
37
|
+
this.statusCode = statusCode;
|
|
38
|
+
this.details = details;
|
|
39
|
+
Error.captureStackTrace(this, this.constructor);
|
|
40
|
+
}
|
|
41
|
+
}`;
|
|
42
|
+
|
|
43
|
+
const subclasses = [
|
|
44
|
+
["BadRequestException", 400, "Bad Request"],
|
|
45
|
+
["UnauthorizedException", 401, "Unauthorized"],
|
|
46
|
+
["ForbiddenException", 403, "Forbidden"],
|
|
47
|
+
["NotFoundException", 404, "Not Found"],
|
|
48
|
+
["ConflictException", 409, "Conflict"],
|
|
49
|
+
["UnprocessableEntityException", 422, "Unprocessable Entity"],
|
|
50
|
+
["InternalServerErrorException", 500, "Internal Server Error"],
|
|
51
|
+
].map(([name, status, message]) => {
|
|
52
|
+
const ctorArgs = typedCtorArgs.sub.replace("__DEFAULT__", message);
|
|
53
|
+
return `${kw} ${name} extends HTTPException {
|
|
54
|
+
constructor(${ctorArgs}) {
|
|
55
|
+
super(${status}, message, details);
|
|
56
|
+
this.name = "${name}";
|
|
57
|
+
}
|
|
58
|
+
}`;
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const body = [base, ...subclasses].join("\n\n");
|
|
62
|
+
const footer = esm ? "" : `\n\nmodule.exports = {\n ${NAMES.join(",\n ")},\n};\n`;
|
|
63
|
+
|
|
64
|
+
return `${body}${footer}\n`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
module.exports = { generateErrorsFile };
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { isEsm } = require("../syntax");
|
|
4
|
+
const { sharedDir } = require("../paths");
|
|
5
|
+
const { resolveImportPath } = require("../aliasResolver");
|
|
6
|
+
|
|
7
|
+
/** helpers/bcrypt.{js,ts} — the only place password hashing logic lives. */
|
|
8
|
+
function generateBcryptHelper(config) {
|
|
9
|
+
const esm = isEsm(config);
|
|
10
|
+
const isTs = config.language === "ts";
|
|
11
|
+
const imports = esm ? `import bcrypt from "bcrypt";` : `const bcrypt = require("bcrypt");`;
|
|
12
|
+
const SALT_ROUNDS = 10;
|
|
13
|
+
|
|
14
|
+
const hashSig = isTs ? "hashPassword(password: string)" : "hashPassword(password)";
|
|
15
|
+
const compareSig = isTs
|
|
16
|
+
? "comparePassword(password: string, hash: string)"
|
|
17
|
+
: "comparePassword(password, hash)";
|
|
18
|
+
|
|
19
|
+
return `${imports}
|
|
20
|
+
|
|
21
|
+
const SALT_ROUNDS = ${SALT_ROUNDS};
|
|
22
|
+
|
|
23
|
+
async function ${hashSig} {
|
|
24
|
+
return bcrypt.hash(password, SALT_ROUNDS);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function ${compareSig} {
|
|
28
|
+
return bcrypt.compare(password, hash);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
${esm ? "export { hashPassword, comparePassword };" : "module.exports = { hashPassword, comparePassword };"}
|
|
32
|
+
`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** helpers/jwt.{js,ts} — access/refresh token signing and verification, centralized. */
|
|
36
|
+
function generateJwtHelper(config, aliasConfig) {
|
|
37
|
+
const esm = isEsm(config);
|
|
38
|
+
const isTs = config.language === "ts";
|
|
39
|
+
const hasRefresh = config.authentication.methods.includes("refresh-token");
|
|
40
|
+
const fromDir = sharedDir(config, "helpers");
|
|
41
|
+
// helpers/env is a same-directory sibling of helpers/jwt, so this resolves to "./env" either way.
|
|
42
|
+
const envPath = resolveImportPath(config, aliasConfig, fromDir, `${fromDir}/env`);
|
|
43
|
+
|
|
44
|
+
const imports = isTs
|
|
45
|
+
? `import jwt, { SignOptions } from "jsonwebtoken";`
|
|
46
|
+
: esm
|
|
47
|
+
? `import jwt from "jsonwebtoken";`
|
|
48
|
+
: `const jwt = require("jsonwebtoken");`;
|
|
49
|
+
const envImport = esm ? `import { env } from "${envPath}";` : `const { env } = require("${envPath}");`;
|
|
50
|
+
|
|
51
|
+
const expiresInCast = isTs ? " as SignOptions[\"expiresIn\"]" : "";
|
|
52
|
+
const signAccessSig = isTs ? `signAccessToken(payload: object)` : "signAccessToken(payload)";
|
|
53
|
+
const verifyAccessSig = isTs ? "verifyAccessToken(token: string)" : "verifyAccessToken(token)";
|
|
54
|
+
|
|
55
|
+
let refreshFns = "";
|
|
56
|
+
if (hasRefresh) {
|
|
57
|
+
const signRefreshSig = isTs ? "signRefreshToken(payload: object)" : "signRefreshToken(payload)";
|
|
58
|
+
const verifyRefreshSig = isTs ? "verifyRefreshToken(token: string)" : "verifyRefreshToken(token)";
|
|
59
|
+
refreshFns = `
|
|
60
|
+
function ${signRefreshSig} {
|
|
61
|
+
return jwt.sign(payload, env.JWT_REFRESH_SECRET, { expiresIn: env.JWT_REFRESH_EXPIRES_IN${expiresInCast} });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function ${verifyRefreshSig} {
|
|
65
|
+
return jwt.verify(token, env.JWT_REFRESH_SECRET);
|
|
66
|
+
}
|
|
67
|
+
`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const exportsList = ["signAccessToken", "verifyAccessToken"];
|
|
71
|
+
if (hasRefresh) exportsList.push("signRefreshToken", "verifyRefreshToken");
|
|
72
|
+
|
|
73
|
+
return `${imports}
|
|
74
|
+
${envImport}
|
|
75
|
+
|
|
76
|
+
function ${signAccessSig} {
|
|
77
|
+
return jwt.sign(payload, env.JWT_SECRET, { expiresIn: env.JWT_EXPIRES_IN${expiresInCast} });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function ${verifyAccessSig} {
|
|
81
|
+
return jwt.verify(token, env.JWT_SECRET);
|
|
82
|
+
}
|
|
83
|
+
${refreshFns}
|
|
84
|
+
${esm ? `export { ${exportsList.join(", ")} };` : `module.exports = { ${exportsList.join(", ")} };`}
|
|
85
|
+
`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** middlewares/auth.{js,ts} — verifies the Bearer access token and attaches req.user. */
|
|
89
|
+
function generateAuthMiddleware(config, aliasConfig) {
|
|
90
|
+
const esm = isEsm(config);
|
|
91
|
+
const isTs = config.language === "ts";
|
|
92
|
+
const fromDir = sharedDir(config, "middlewares");
|
|
93
|
+
const jwtPath = resolveImportPath(config, aliasConfig, fromDir, `${sharedDir(config, "helpers")}/jwt`);
|
|
94
|
+
const errorsPath = resolveImportPath(config, aliasConfig, fromDir, `${sharedDir(config, "helpers")}/errors`);
|
|
95
|
+
|
|
96
|
+
const jwtImport = esm
|
|
97
|
+
? `import { verifyAccessToken } from "${jwtPath}";`
|
|
98
|
+
: `const { verifyAccessToken } = require("${jwtPath}");`;
|
|
99
|
+
const errorsImport = esm
|
|
100
|
+
? `import { UnauthorizedException } from "${errorsPath}";`
|
|
101
|
+
: `const { UnauthorizedException } = require("${errorsPath}");`;
|
|
102
|
+
const reqType = isTs ? `import { Request, Response, NextFunction } from "express";\n\n` : "";
|
|
103
|
+
const sig = isTs ? "(req: Request, res: Response, next: NextFunction)" : "(req, res, next)";
|
|
104
|
+
|
|
105
|
+
return `${reqType}${jwtImport}
|
|
106
|
+
${errorsImport}
|
|
107
|
+
|
|
108
|
+
function authenticate${sig} {
|
|
109
|
+
const header = req.headers.authorization;
|
|
110
|
+
|
|
111
|
+
if (!header || !header.startsWith("Bearer ")) {
|
|
112
|
+
return next(new UnauthorizedException("Missing or malformed Authorization header"));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const token = header.slice("Bearer ".length);
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
req.user = verifyAccessToken(token);
|
|
119
|
+
return next();
|
|
120
|
+
} catch (error) {
|
|
121
|
+
return next(new UnauthorizedException("Invalid or expired token"));
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
${esm ? "export { authenticate };" : "module.exports = { authenticate };"}
|
|
126
|
+
`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* helpers/oauth.stub.{js,ts} — GAZAN does NOT generate a working OAuth integration. "OAuth" isn't
|
|
131
|
+
* one thing — Google, GitHub, generic OIDC, etc. all need different scopes, callback handling, and
|
|
132
|
+
* token exchange, so wiring a specific provider is left to the project. This file exists so the
|
|
133
|
+
* gap is obvious and documented rather than a silently missing piece; see README's Authentication
|
|
134
|
+
* section for the same note.
|
|
135
|
+
*/
|
|
136
|
+
function generateOAuthStub(config) {
|
|
137
|
+
const esm = isEsm(config);
|
|
138
|
+
const isTs = config.language === "ts";
|
|
139
|
+
const sig = isTs ? "notConfigured(): never" : "notConfigured()";
|
|
140
|
+
|
|
141
|
+
return `/**
|
|
142
|
+
* OAuth is scaffolded as env vars only (OAUTH_CLIENT_ID / OAUTH_CLIENT_SECRET /
|
|
143
|
+
* OAUTH_CALLBACK_URL) — GAZAN does not generate a provider integration. To finish this:
|
|
144
|
+
*
|
|
145
|
+
* 1. Pick a provider (Google, GitHub, a generic OIDC server, ...).
|
|
146
|
+
* 2. Add its client library (e.g. \`openid-client\`, or a provider-specific SDK) yourself —
|
|
147
|
+
* none is installed by default.
|
|
148
|
+
* 3. Implement the redirect + callback routes (e.g. GET /auth/oauth, GET /auth/oauth/callback)
|
|
149
|
+
* using env.OAUTH_CLIENT_ID / env.OAUTH_CLIENT_SECRET / env.OAUTH_CALLBACK_URL below.
|
|
150
|
+
* 4. On success, mint your own access token the same way helpers/jwt.${isTs ? "ts" : "js"} does,
|
|
151
|
+
* so the rest of the app (middlewares/auth) keeps working unchanged.
|
|
152
|
+
*/
|
|
153
|
+
function ${sig} {
|
|
154
|
+
throw new Error(
|
|
155
|
+
"OAuth is not implemented — this is a stub. See helpers/oauth.stub.${isTs ? "ts" : "js"} for what to build."
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
${esm ? "export { notConfigured };" : "module.exports = { notConfigured };"}
|
|
160
|
+
`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
module.exports = { generateBcryptHelper, generateJwtHelper, generateAuthMiddleware, generateOAuthStub };
|