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
package/bin/gazan.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
const REQUIRED_MAJOR = 18;
|
|
5
|
+
const currentMajor = Number(process.versions.node.split(".")[0]);
|
|
6
|
+
|
|
7
|
+
if (Number.isNaN(currentMajor) || currentMajor < REQUIRED_MAJOR) {
|
|
8
|
+
console.error(
|
|
9
|
+
`GAZAN requires Node.js >= ${REQUIRED_MAJOR}. You're running Node ${process.versions.node}.\n` +
|
|
10
|
+
`Install a newer Node (e.g. via nvm) and try again.`
|
|
11
|
+
);
|
|
12
|
+
process.exit(1);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const { createProgram } = require("../src/cli");
|
|
16
|
+
|
|
17
|
+
createProgram().parseAsync(process.argv);
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "gazan-init",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Interactive CLI that generates production-ready Node.js backend projects — architecture, database, auth, Redis, BullMQ, Socket.IO, and module aliases, all conditional on what you actually select.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"cli",
|
|
7
|
+
"generator",
|
|
8
|
+
"scaffolding",
|
|
9
|
+
"boilerplate",
|
|
10
|
+
"backend",
|
|
11
|
+
"express",
|
|
12
|
+
"prisma",
|
|
13
|
+
"mongoose",
|
|
14
|
+
"mongodb",
|
|
15
|
+
"postgresql",
|
|
16
|
+
"typescript",
|
|
17
|
+
"mvc",
|
|
18
|
+
"hmvc",
|
|
19
|
+
"code-generator",
|
|
20
|
+
"project-generator"
|
|
21
|
+
],
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"author": "Korveniq Technologies",
|
|
24
|
+
"type": "commonjs",
|
|
25
|
+
"bin": {
|
|
26
|
+
"gazan": "bin/gazan.js"
|
|
27
|
+
},
|
|
28
|
+
"main": "src/generators/index.js",
|
|
29
|
+
"files": [
|
|
30
|
+
"bin",
|
|
31
|
+
"src"
|
|
32
|
+
],
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=18"
|
|
35
|
+
},
|
|
36
|
+
"repository": {
|
|
37
|
+
"type": "git",
|
|
38
|
+
"url": "git+https://github.com/korveniq/Gazan.git"
|
|
39
|
+
},
|
|
40
|
+
"homepage": "https://github.com/korveniq/Gazan#readme",
|
|
41
|
+
"bugs": {
|
|
42
|
+
"url": "https://github.com/korveniq/Gazan/issues"
|
|
43
|
+
},
|
|
44
|
+
"scripts": {
|
|
45
|
+
"test": "node tests/run.js"
|
|
46
|
+
},
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"@clack/prompts": "^0.7.0",
|
|
49
|
+
"chalk": "^4.1.2",
|
|
50
|
+
"commander": "^12.1.0",
|
|
51
|
+
"fs-extra": "^11.2.0",
|
|
52
|
+
"zod": "^3.23.8"
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const path = require("path");
|
|
4
|
+
const os = require("os");
|
|
5
|
+
const crypto = require("crypto");
|
|
6
|
+
const fs = require("fs-extra");
|
|
7
|
+
const p = require("@clack/prompts");
|
|
8
|
+
const chalk = require("chalk");
|
|
9
|
+
const { runPrompts } = require("../../prompts");
|
|
10
|
+
const { normalizeConfig } = require("../../config/normalize");
|
|
11
|
+
const { isDirEmpty } = require("../../utils/fsSafety");
|
|
12
|
+
const { readEntityFile } = require("../../parser/entity/parse");
|
|
13
|
+
const { EntityValidationError } = require("../../parser/entity/errors");
|
|
14
|
+
const { AliasCollisionError } = require("../../config/aliases");
|
|
15
|
+
const { generate } = require("../../generators");
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Generates into a scratch directory first, only copying into `targetDir` once generation
|
|
19
|
+
* succeeds fully — so a bug or filesystem error partway through never leaves the user's real
|
|
20
|
+
* project directory half-written.
|
|
21
|
+
*/
|
|
22
|
+
function generateAtomically(targetDir, config, entityModel) {
|
|
23
|
+
const tmpDir = path.join(os.tmpdir(), `gazan-${Date.now()}-${crypto.randomBytes(4).toString("hex")}`);
|
|
24
|
+
try {
|
|
25
|
+
const { results, warnings } = generate(tmpDir, config, entityModel);
|
|
26
|
+
fs.ensureDirSync(targetDir);
|
|
27
|
+
fs.copySync(tmpDir, targetDir, { overwrite: true });
|
|
28
|
+
return { results, warnings };
|
|
29
|
+
} finally {
|
|
30
|
+
fs.removeSync(tmpDir);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function resolveTargetDir(projectName) {
|
|
35
|
+
let targetDir = path.resolve(process.cwd(), projectName);
|
|
36
|
+
|
|
37
|
+
for (;;) {
|
|
38
|
+
if (isDirEmpty(targetDir)) return targetDir;
|
|
39
|
+
|
|
40
|
+
const choice = await p.select({
|
|
41
|
+
message: `Directory "${path.relative(process.cwd(), targetDir) || targetDir}" is not empty.`,
|
|
42
|
+
options: [
|
|
43
|
+
{ value: "cancel", label: "Cancel" },
|
|
44
|
+
{ value: "continue", label: "Continue (files may be overwritten)" },
|
|
45
|
+
{ value: "other", label: "Use another directory" },
|
|
46
|
+
],
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
if (p.isCancel(choice) || choice === "cancel") {
|
|
50
|
+
p.cancel("Operation cancelled.");
|
|
51
|
+
process.exit(1);
|
|
52
|
+
}
|
|
53
|
+
if (choice === "continue") return targetDir;
|
|
54
|
+
|
|
55
|
+
const otherName = await p.text({
|
|
56
|
+
message: "Target directory path",
|
|
57
|
+
placeholder: "./my-app",
|
|
58
|
+
});
|
|
59
|
+
if (p.isCancel(otherName)) {
|
|
60
|
+
p.cancel("Operation cancelled.");
|
|
61
|
+
process.exit(1);
|
|
62
|
+
}
|
|
63
|
+
targetDir = path.resolve(process.cwd(), otherName);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function runInit() {
|
|
68
|
+
const answers = await runPrompts();
|
|
69
|
+
|
|
70
|
+
const targetDir = await resolveTargetDir(answers.projectName);
|
|
71
|
+
answers.targetDir = targetDir;
|
|
72
|
+
|
|
73
|
+
const config = normalizeConfig(answers);
|
|
74
|
+
|
|
75
|
+
let entityModel = null;
|
|
76
|
+
if (config.entityFile) {
|
|
77
|
+
try {
|
|
78
|
+
entityModel = readEntityFile(config.entityFile, { databaseType: config.database.type });
|
|
79
|
+
} catch (error) {
|
|
80
|
+
if (error instanceof EntityValidationError) {
|
|
81
|
+
p.log.error(error.format());
|
|
82
|
+
} else {
|
|
83
|
+
p.log.error(error.message);
|
|
84
|
+
}
|
|
85
|
+
process.exit(1);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const spinner = p.spinner();
|
|
90
|
+
spinner.start("Generating project...");
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
const { results, warnings } = generateAtomically(targetDir, config, entityModel);
|
|
94
|
+
spinner.stop("Project generated.");
|
|
95
|
+
for (const step of results) {
|
|
96
|
+
p.log.success(step);
|
|
97
|
+
}
|
|
98
|
+
for (const warning of warnings) {
|
|
99
|
+
p.log.warn(warning);
|
|
100
|
+
}
|
|
101
|
+
} catch (error) {
|
|
102
|
+
spinner.stop("Generation failed — target directory left untouched.");
|
|
103
|
+
if (error instanceof AliasCollisionError) {
|
|
104
|
+
p.log.error(error.message);
|
|
105
|
+
} else {
|
|
106
|
+
p.log.error(error.stack || error.message);
|
|
107
|
+
}
|
|
108
|
+
process.exit(1);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const relTarget = path.relative(process.cwd(), targetDir) || ".";
|
|
112
|
+
|
|
113
|
+
p.outro(
|
|
114
|
+
[
|
|
115
|
+
chalk.bold.green("Project initialized successfully."),
|
|
116
|
+
"",
|
|
117
|
+
"Next steps:",
|
|
118
|
+
"",
|
|
119
|
+
` cd ${relTarget}`,
|
|
120
|
+
` npm install`,
|
|
121
|
+
` npm run dev`,
|
|
122
|
+
].join("\n")
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
module.exports = { runInit };
|
package/src/cli/index.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { Command } = require("commander");
|
|
4
|
+
const { runInit } = require("./commands/init");
|
|
5
|
+
const pkg = require("../../package.json");
|
|
6
|
+
|
|
7
|
+
function createProgram() {
|
|
8
|
+
const program = new Command();
|
|
9
|
+
|
|
10
|
+
program.name("gazan").description("Interactive backend project initializer.").version(pkg.version);
|
|
11
|
+
|
|
12
|
+
program
|
|
13
|
+
.command("init")
|
|
14
|
+
.description("Interactively generate a new backend project.")
|
|
15
|
+
.action(async () => {
|
|
16
|
+
await runInit();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
return program;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
module.exports = { createProgram };
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { pluralize } = require("../utils/strings");
|
|
4
|
+
|
|
5
|
+
class AliasCollisionError extends Error {
|
|
6
|
+
constructor(message) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = "AliasCollisionError";
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Every alias GAZAN itself can generate — used to guard entity-derived HMVC module aliases from
|
|
13
|
+
// colliding with a shared/infra alias (section 11's reserved list, extended with the ones GAZAN
|
|
14
|
+
// actually produces beyond the spec's minimum example, e.g. @models).
|
|
15
|
+
const RESERVED_ALIAS_NAMES = new Set([
|
|
16
|
+
"@",
|
|
17
|
+
"@controllers",
|
|
18
|
+
"@services",
|
|
19
|
+
"@routes",
|
|
20
|
+
"@middlewares",
|
|
21
|
+
"@utils",
|
|
22
|
+
"@helpers",
|
|
23
|
+
"@validators",
|
|
24
|
+
"@configs",
|
|
25
|
+
"@db",
|
|
26
|
+
"@redis",
|
|
27
|
+
"@socket",
|
|
28
|
+
"@workers",
|
|
29
|
+
"@models",
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
function join(...segments) {
|
|
33
|
+
const filtered = segments.filter((s) => s && s !== ".");
|
|
34
|
+
return filtered.length > 0 ? filtered.join("/") : ".";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The single source of truth for the project's alias set. Consumed by the import resolver,
|
|
39
|
+
* the tsconfig/jsconfig generators, the CJS/MJS runtime generators, and the README generator —
|
|
40
|
+
* none of them re-derive this mapping independently.
|
|
41
|
+
*
|
|
42
|
+
* Returns { enabled: false } untouched when aliases are off, so every downstream consumer can
|
|
43
|
+
* just check `.enabled` once and otherwise behave exactly as it did before this feature existed.
|
|
44
|
+
*/
|
|
45
|
+
function buildAliasConfig(config, entityModel) {
|
|
46
|
+
if (!config.aliases || !config.aliases.enabled) {
|
|
47
|
+
return { enabled: false, root: "@", aliases: {}, modules: {} };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const root = config.useSrc ? "src" : ".";
|
|
51
|
+
const aliases = { "@": root };
|
|
52
|
+
|
|
53
|
+
if (config.architecture === "mvc") {
|
|
54
|
+
aliases["@controllers"] = join(root, "controllers");
|
|
55
|
+
aliases["@routes"] = join(root, "routes");
|
|
56
|
+
aliases["@services"] = join(root, "services");
|
|
57
|
+
aliases["@validators"] = join(root, "validators");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Shared regardless of architecture — generateProject always creates these.
|
|
61
|
+
aliases["@middlewares"] = join(root, "middlewares");
|
|
62
|
+
aliases["@utils"] = join(root, "utils");
|
|
63
|
+
aliases["@helpers"] = join(root, "helpers");
|
|
64
|
+
aliases["@configs"] = join(root, "configs");
|
|
65
|
+
|
|
66
|
+
if (config.database.type !== "none") {
|
|
67
|
+
aliases["@db"] = join(root, "configs", "db");
|
|
68
|
+
}
|
|
69
|
+
if (config.redis) {
|
|
70
|
+
aliases["@redis"] = join(root, "configs", "redis");
|
|
71
|
+
}
|
|
72
|
+
if (config.socketIO) {
|
|
73
|
+
aliases["@socket"] = join(root, "socket");
|
|
74
|
+
}
|
|
75
|
+
if (config.bullMQ) {
|
|
76
|
+
aliases["@workers"] = join(root, "workers");
|
|
77
|
+
}
|
|
78
|
+
if (config.database.type === "mongodb" && config.database.orm === "mongoose") {
|
|
79
|
+
aliases["@models"] = join(root, "models");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const modules = {};
|
|
83
|
+
if (config.architecture === "hmvc" && entityModel) {
|
|
84
|
+
const seenTargets = new Map();
|
|
85
|
+
for (const model of entityModel.models) {
|
|
86
|
+
if (model.crud === false) continue;
|
|
87
|
+
|
|
88
|
+
// model.camelName is already validated by the entity parser (letters/digits only,
|
|
89
|
+
// starting with a letter), so pluralize() cannot introduce path-traversal or unsafe
|
|
90
|
+
// characters — no separate sanitization is needed here.
|
|
91
|
+
const key = `@${pluralize(model.camelName)}`;
|
|
92
|
+
|
|
93
|
+
if (RESERVED_ALIAS_NAMES.has(key)) {
|
|
94
|
+
throw new AliasCollisionError(
|
|
95
|
+
`Module alias '${key}' (derived from model '${model.name}') collides with a reserved GAZAN alias. Rename the model, or disable module aliases.`
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
if (seenTargets.has(key)) {
|
|
99
|
+
throw new AliasCollisionError(
|
|
100
|
+
`Module alias '${key}' is derived from both '${seenTargets.get(key)}' and '${model.name}' — their pluralized names collide. Rename one of the models, or disable module aliases.`
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
seenTargets.set(key, model.name);
|
|
104
|
+
|
|
105
|
+
modules[key] = join(root, "modules", model.camelName);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return { enabled: true, root: "@", aliases, modules };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** All alias entries (shared + HMVC module) as a single flat map — key -> target directory. */
|
|
113
|
+
function allAliasEntries(aliasConfig) {
|
|
114
|
+
return { ...aliasConfig.aliases, ...aliasConfig.modules };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Derives tsconfig/jsconfig `paths` (and their `baseUrl`) from the directory map — the only place
|
|
119
|
+
* that shape is computed, so tsconfig.json and jsconfig.json can never drift from each other or
|
|
120
|
+
* from the runtime resolvers.
|
|
121
|
+
*/
|
|
122
|
+
function toTsPaths(aliasConfig) {
|
|
123
|
+
const paths = {};
|
|
124
|
+
for (const [key, dir] of Object.entries(allAliasEntries(aliasConfig))) {
|
|
125
|
+
paths[`${key}/*`] = [`${dir === "." ? "" : dir + "/"}*`];
|
|
126
|
+
}
|
|
127
|
+
return { baseUrl: ".", paths };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
module.exports = { buildAliasConfig, allAliasEntries, toTsPaths, AliasCollisionError, RESERVED_ALIAS_NAMES };
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Turns raw prompt answers into the single normalized configuration object
|
|
5
|
+
* that every generator consumes. Generators must never read prompt answers
|
|
6
|
+
* directly — only this shape.
|
|
7
|
+
*/
|
|
8
|
+
function normalizeConfig(answers) {
|
|
9
|
+
const moduleSystem = answers.moduleSystem === "mjs" ? "mjs" : "cjs";
|
|
10
|
+
const language = answers.language === "ts" ? "ts" : "js";
|
|
11
|
+
const ext = language === "ts" ? "ts" : "js";
|
|
12
|
+
|
|
13
|
+
const database = normalizeDatabase(answers.database);
|
|
14
|
+
const architecture = answers.architecture === "hmvc" ? "hmvc" : "mvc";
|
|
15
|
+
const useSrc = Boolean(answers.useSrc);
|
|
16
|
+
const socketIO = Boolean(answers.socketIO);
|
|
17
|
+
const bullMQ = Boolean(answers.bullMQ);
|
|
18
|
+
|
|
19
|
+
const authentication = normalizeAuth(answers.authentication);
|
|
20
|
+
|
|
21
|
+
const rateLimitStrategy = bullMQ ? "redis" : answers.rateLimitStrategy || "memory";
|
|
22
|
+
const needsRedis = bullMQ || rateLimitStrategy === "redis";
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
projectName: answers.projectName,
|
|
26
|
+
targetDir: answers.targetDir,
|
|
27
|
+
moduleSystem,
|
|
28
|
+
language,
|
|
29
|
+
ext,
|
|
30
|
+
aliases: { enabled: Boolean(answers.aliasesEnabled) },
|
|
31
|
+
database,
|
|
32
|
+
architecture,
|
|
33
|
+
useSrc,
|
|
34
|
+
socketIO,
|
|
35
|
+
bullMQ,
|
|
36
|
+
redis: needsRedis,
|
|
37
|
+
rateLimitStrategy: needsRedis ? "redis" : "memory",
|
|
38
|
+
authentication,
|
|
39
|
+
entityFile: answers.entityFile || null,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function normalizeDatabase(db) {
|
|
44
|
+
if (!db || db.type === "none") {
|
|
45
|
+
return { type: "none", orm: null };
|
|
46
|
+
}
|
|
47
|
+
if (db.type === "postgresql") {
|
|
48
|
+
return { type: "postgresql", orm: "prisma" };
|
|
49
|
+
}
|
|
50
|
+
if (db.type === "mongodb") {
|
|
51
|
+
return { type: "mongodb", orm: db.orm === "native" ? "native" : "mongoose" };
|
|
52
|
+
}
|
|
53
|
+
return { type: "none", orm: null };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function normalizeAuth(auth) {
|
|
57
|
+
if (!auth || !auth.enabled) {
|
|
58
|
+
return { enabled: false, methods: [] };
|
|
59
|
+
}
|
|
60
|
+
const allowed = ["email-password", "jwt", "oauth", "refresh-token"];
|
|
61
|
+
const methods = (auth.methods || []).filter((m) => allowed.includes(m));
|
|
62
|
+
return { enabled: methods.length > 0, methods };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
module.exports = { normalizeConfig };
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { relativeImport } = require("./paths");
|
|
4
|
+
const { specifier } = require("./syntax");
|
|
5
|
+
const { allAliasEntries } = require("../config/aliases");
|
|
6
|
+
|
|
7
|
+
function normalizeSlashes(p) {
|
|
8
|
+
return p.split("\\").join("/");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Finds the alias whose target directory is the deepest (most specific) match for
|
|
13
|
+
* `targetPathNoExt` — e.g. for "src/configs/db/index" this prefers "@db" (src/configs/db) over
|
|
14
|
+
* the root "@" (src), even though both technically match.
|
|
15
|
+
*/
|
|
16
|
+
function findLongestAliasMatch(aliasConfig, targetPathNoExt) {
|
|
17
|
+
const target = normalizeSlashes(targetPathNoExt);
|
|
18
|
+
let best = null;
|
|
19
|
+
|
|
20
|
+
for (const [key, dir] of Object.entries(allAliasEntries(aliasConfig))) {
|
|
21
|
+
const normalizedDir = normalizeSlashes(dir).replace(/^\.\/?$/, "");
|
|
22
|
+
const isRoot = normalizedDir === "";
|
|
23
|
+
const prefix = isRoot ? "" : `${normalizedDir}/`;
|
|
24
|
+
|
|
25
|
+
const matches = isRoot ? true : target === normalizedDir || target.startsWith(prefix);
|
|
26
|
+
if (!matches) continue;
|
|
27
|
+
|
|
28
|
+
const dirLen = isRoot ? 0 : normalizedDir.length;
|
|
29
|
+
if (!best || dirLen > best.dirLen) {
|
|
30
|
+
const remainder = isRoot ? target : target.slice(normalizedDir.length).replace(/^\//, "");
|
|
31
|
+
best = { key, dirLen, remainder };
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return best;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The one place that decides how a generated file imports another generated file. Falls back to
|
|
40
|
+
* the pre-existing relative-path behavior whenever aliases are disabled, the two files are in the
|
|
41
|
+
* same directory (aliasing a sibling import adds noise, not clarity), or — defensively — no alias
|
|
42
|
+
* covers the target at all.
|
|
43
|
+
*
|
|
44
|
+
* @param config normalized project config
|
|
45
|
+
* @param aliasConfig result of buildAliasConfig() (or {enabled:false} / undefined)
|
|
46
|
+
* @param fromDir directory (relative to project root) of the file doing the importing
|
|
47
|
+
* @param targetPathNoExt path (relative to project root, no extension) of the file being imported
|
|
48
|
+
*/
|
|
49
|
+
function resolveImportPath(config, aliasConfig, fromDir, targetPathNoExt) {
|
|
50
|
+
const plainRelative = relativeImport(fromDir, targetPathNoExt);
|
|
51
|
+
const isSameDirectory = plainRelative.startsWith("./") && !plainRelative.slice(2).includes("/");
|
|
52
|
+
|
|
53
|
+
if (isSameDirectory || !aliasConfig || !aliasConfig.enabled) {
|
|
54
|
+
return specifier(config, plainRelative);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const hit = findLongestAliasMatch(aliasConfig, targetPathNoExt);
|
|
58
|
+
if (!hit) return specifier(config, plainRelative); // defensive: every real dir GAZAN writes to has an alias
|
|
59
|
+
|
|
60
|
+
const aliasSpecifier = hit.remainder ? `${hit.key}/${hit.remainder}` : hit.key;
|
|
61
|
+
// Under native ESM (moduleSystem "mjs"), both Node's own resolution and TypeScript's NodeNext
|
|
62
|
+
// resolver require an explicit extension even on path-mapped/alias specifiers — the same rule
|
|
63
|
+
// specifier() already applies to plain relative imports, so reuse it here rather than assuming
|
|
64
|
+
// "aliases never need extensions" (that's only true for CJS resolution and module-alias).
|
|
65
|
+
return specifier(config, aliasSpecifier);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
module.exports = { resolveImportPath, findLongestAliasMatch };
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { allAliasEntries, toTsPaths } = require("../config/aliases");
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* package.json `_moduleAliases` fragment consumed by the `module-alias` package — CJS+JS only.
|
|
7
|
+
* `module-alias/register` (required as the first line of server.js) reads this at process start.
|
|
8
|
+
*/
|
|
9
|
+
function buildModuleAliasPackageFragment(aliasConfig) {
|
|
10
|
+
return { _moduleAliases: allAliasEntries(aliasConfig) };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* alias-loader.mjs — a small custom ESM loader hook for MJS+JS, where neither `module-alias`
|
|
15
|
+
* (CJS-require-only) nor TypeScript's `paths` (type-checking only, not a runtime mechanism) apply.
|
|
16
|
+
* Registered via `node --experimental-loader=./alias-loader.mjs`. Intercepts only bare `@...`
|
|
17
|
+
* specifiers; everything else passes straight through to Node's normal resolution.
|
|
18
|
+
*/
|
|
19
|
+
function generateAliasLoaderFile(aliasConfig) {
|
|
20
|
+
const entries = allAliasEntries(aliasConfig);
|
|
21
|
+
const aliasLiteral = JSON.stringify(entries, null, 2);
|
|
22
|
+
|
|
23
|
+
return `// Generated by GAZAN. Resolves the project's @-prefixed import aliases for native ESM,
|
|
24
|
+
// where neither module-alias (CommonJS-only) nor TypeScript's "paths" (type-checking only) apply.
|
|
25
|
+
// Registered via: node --experimental-loader=./alias-loader.mjs
|
|
26
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
27
|
+
import path from "node:path";
|
|
28
|
+
|
|
29
|
+
const ALIASES = ${aliasLiteral};
|
|
30
|
+
|
|
31
|
+
// Longest target first, so a more specific alias (e.g. "@db") is tried before a shorter one
|
|
32
|
+
// that would also match (e.g. the root "@").
|
|
33
|
+
const sortedAliases = Object.entries(ALIASES).sort((a, b) => b[1].length - a[1].length);
|
|
34
|
+
|
|
35
|
+
const projectRoot = fileURLToPath(new URL(".", import.meta.url));
|
|
36
|
+
|
|
37
|
+
export async function resolve(specifier, context, nextResolve) {
|
|
38
|
+
for (const [alias, dir] of sortedAliases) {
|
|
39
|
+
if (specifier === alias || specifier.startsWith(\`\${alias}/\`)) {
|
|
40
|
+
const rest = specifier.slice(alias.length).replace(/^\\//, "");
|
|
41
|
+
let resolvedPath = path.join(projectRoot, dir, rest);
|
|
42
|
+
// Generated specifiers already carry .js, but resolve defensively for any that don't
|
|
43
|
+
// (native ESM has no extension-guessing of its own).
|
|
44
|
+
if (!path.extname(resolvedPath)) resolvedPath += ".js";
|
|
45
|
+
return nextResolve(pathToFileURL(resolvedPath).href, context);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return nextResolve(specifier, context);
|
|
49
|
+
}
|
|
50
|
+
`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** tsconfig.json paths fragment — merged into the base tsconfig by the tsconfig generator. */
|
|
54
|
+
function buildTsconfigAliasFragment(aliasConfig) {
|
|
55
|
+
return toTsPaths(aliasConfig);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* jsconfig.json — plain JS projects get no TypeScript compiler, but VS Code (and most editors)
|
|
60
|
+
* read jsconfig.json for path-alias-aware intellisense/go-to-definition. Purely a DX aid; it has
|
|
61
|
+
* no effect on how the generated project actually runs (module-alias / the MJS loader do that).
|
|
62
|
+
*/
|
|
63
|
+
function buildJsconfigJson(aliasConfig) {
|
|
64
|
+
const { baseUrl, paths } = toTsPaths(aliasConfig);
|
|
65
|
+
return {
|
|
66
|
+
compilerOptions: {
|
|
67
|
+
baseUrl,
|
|
68
|
+
paths,
|
|
69
|
+
},
|
|
70
|
+
exclude: ["node_modules", "dist"],
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
module.exports = {
|
|
75
|
+
buildModuleAliasPackageFragment,
|
|
76
|
+
generateAliasLoaderFile,
|
|
77
|
+
buildTsconfigAliasFragment,
|
|
78
|
+
buildJsconfigJson,
|
|
79
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { isEsm } = require("../syntax");
|
|
4
|
+
const { sharedDir } = require("../paths");
|
|
5
|
+
const { resolveImportPath } = require("../aliasResolver");
|
|
6
|
+
|
|
7
|
+
/** configs/db/index.{js,ts} — centralized native MongoDB driver client lifecycle. */
|
|
8
|
+
function generateMongoNativeDbConfig(config, aliasConfig) {
|
|
9
|
+
const esm = isEsm(config);
|
|
10
|
+
const isTs = config.language === "ts";
|
|
11
|
+
const fromDir = `${sharedDir(config, "configs")}/db`;
|
|
12
|
+
const envPath = resolveImportPath(config, aliasConfig, fromDir, `${sharedDir(config, "helpers")}/env`);
|
|
13
|
+
|
|
14
|
+
const imports = esm ? `import { MongoClient } from "mongodb";` : `const { MongoClient } = require("mongodb");`;
|
|
15
|
+
const envImport = esm ? `import { env } from "${envPath}";` : `const { env } = require("${envPath}");`;
|
|
16
|
+
|
|
17
|
+
return `${imports}
|
|
18
|
+
${envImport}
|
|
19
|
+
|
|
20
|
+
const client = new MongoClient(env.MONGODB_URI);
|
|
21
|
+
let db${isTs ? ": import(\"mongodb\").Db | undefined" : ""};
|
|
22
|
+
let connected = false;
|
|
23
|
+
|
|
24
|
+
async function connect() {
|
|
25
|
+
if (connected) return;
|
|
26
|
+
await client.connect();
|
|
27
|
+
db = client.db();
|
|
28
|
+
connected = true;
|
|
29
|
+
console.log("[db] mongodb connected");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function getDb() {
|
|
33
|
+
if (!db) throw new Error("Database not connected. Call connect() first.");
|
|
34
|
+
return db;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function disconnect() {
|
|
38
|
+
await client.close();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
${esm ? "export { client, getDb, connect, disconnect };" : "module.exports = { client, getDb, connect, disconnect };"}
|
|
42
|
+
`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
module.exports = { generateMongoNativeDbConfig };
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { isEsm } = require("../syntax");
|
|
4
|
+
const { sharedDir } = require("../paths");
|
|
5
|
+
const { resolveImportPath } = require("../aliasResolver");
|
|
6
|
+
|
|
7
|
+
/** configs/db/index.{js,ts} — centralized Mongoose connection lifecycle. */
|
|
8
|
+
function generateMongooseDbConfig(config, aliasConfig) {
|
|
9
|
+
const esm = isEsm(config);
|
|
10
|
+
const fromDir = `${sharedDir(config, "configs")}/db`;
|
|
11
|
+
const envPath = resolveImportPath(config, aliasConfig, fromDir, `${sharedDir(config, "helpers")}/env`);
|
|
12
|
+
|
|
13
|
+
const imports = esm ? `import mongoose from "mongoose";` : `const mongoose = require("mongoose");`;
|
|
14
|
+
const envImport = esm ? `import { env } from "${envPath}";` : `const { env } = require("${envPath}");`;
|
|
15
|
+
|
|
16
|
+
return `${imports}
|
|
17
|
+
${envImport}
|
|
18
|
+
|
|
19
|
+
async function connect() {
|
|
20
|
+
await mongoose.connect(env.MONGODB_URI);
|
|
21
|
+
console.log("[db] mongodb connected");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function disconnect() {
|
|
25
|
+
await mongoose.disconnect();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
${esm ? "export { mongoose, connect, disconnect };" : "module.exports = { mongoose, connect, disconnect };"}
|
|
29
|
+
`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
module.exports = { generateMongooseDbConfig };
|