@apc-projects/elysia-cli 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.
@@ -0,0 +1,242 @@
1
+ import { existsSync } from "node:fs";
2
+ import { resolve, relative, dirname } from "node:path";
3
+ import {
4
+ SyntaxKind,
5
+ type Project,
6
+ type SourceFile,
7
+ type NewExpression,
8
+ type PropertyAccessExpression,
9
+ } from "ts-morph";
10
+ import { log } from "./logger.js";
11
+
12
+ /** Cible d'injection : un fichier + le nom de la variable de l'instance Elysia. */
13
+ export interface InjectTarget {
14
+ entry: string;
15
+ variable: string;
16
+ }
17
+
18
+ interface ImportSpec {
19
+ names: string[];
20
+ moduleSpecifier: string;
21
+ }
22
+
23
+ /**
24
+ * Greffe un maillon de chaîne (`segment`, commençant par `.`) juste après
25
+ * `new Elysia(...)`. AST-based (ts-morph), robuste au formatage et idempotent
26
+ * via `dedupe` (sous-chaîne dont la présence signale un no-op).
27
+ */
28
+ export function appendChain(
29
+ project: Project,
30
+ target: InjectTarget,
31
+ segment: string,
32
+ opts: { dedupe?: string; ensureImports?: ImportSpec[] } = {},
33
+ ): boolean {
34
+ const source = openEntry(project, target.entry);
35
+ if (!source) return false;
36
+
37
+ const elysiaNew = findElysiaInstance(source, target.variable);
38
+ if (!elysiaNew) {
39
+ log.warn(
40
+ `Aucune instance \`new Elysia()\` (variable \`${target.variable}\`) dans ` +
41
+ `${target.entry}. Segment non injecté.`,
42
+ );
43
+ return false;
44
+ }
45
+
46
+ const scope =
47
+ elysiaNew.getFirstAncestorByKind(SyntaxKind.VariableStatement) ??
48
+ elysiaNew.getFirstAncestorByKind(SyntaxKind.ExpressionStatement);
49
+ const marker = opts.dedupe ?? segment;
50
+ if ((scope ?? source).getText().includes(marker)) {
51
+ log.skipped(`${target.entry} (déjà présent)`);
52
+ return false;
53
+ }
54
+
55
+ elysiaNew.replaceWithText(`${elysiaNew.getText()}\n ${segment}`);
56
+ for (const spec of opts.ensureImports ?? []) ensureImport(source, spec);
57
+
58
+ source.saveSync();
59
+ log.updated(target.entry);
60
+ return true;
61
+ }
62
+
63
+ /** Raccourci : injecte `.use(<useExpr>)`. */
64
+ export function registerUse(
65
+ project: Project,
66
+ target: InjectTarget,
67
+ opts: { useExpr: string; ensureImport?: ImportSpec },
68
+ ): boolean {
69
+ return appendChain(project, target, `.use(${opts.useExpr})`, {
70
+ dedupe: `.use(${opts.useExpr})`,
71
+ ensureImports: opts.ensureImport ? [opts.ensureImport] : undefined,
72
+ });
73
+ }
74
+
75
+ /**
76
+ * Ré-écrit un appel `openapi()` déjà présent dans l'app entry pour y injecter
77
+ * la doc better-auth, et garantit l'import de `OpenAPI`. No-op si aucun appel
78
+ * `openapi(...)` n'est trouvé, ou s'il est déjà intégré.
79
+ */
80
+ export function patchOpenapiWithAuth(
81
+ project: Project,
82
+ appEntry: string,
83
+ authImportPath: string,
84
+ ): boolean {
85
+ const source = openEntry(project, appEntry);
86
+ if (!source) return false;
87
+
88
+ // Déjà intégré.
89
+ if (source.getText().includes("OpenAPI.getPaths")) return false;
90
+
91
+ const call = source
92
+ .getDescendantsOfKind(SyntaxKind.CallExpression)
93
+ .find((c) => c.getExpression().getText() === "openapi");
94
+ if (!call) return false;
95
+
96
+ call.replaceWithText(
97
+ "openapi({ enabled : process.env.NODE_ENV !== \"production\", documentation: { components: await OpenAPI.components, " +
98
+ "paths: await OpenAPI.getPaths() } })",
99
+ );
100
+ ensureImport(source, {
101
+ names: ["OpenAPI"],
102
+ moduleSpecifier: moduleSpecifierFrom(appEntry, authImportPath),
103
+ });
104
+
105
+ source.saveSync();
106
+ log.updated(`${appEntry} (openapi ↔ better-auth intégré)`);
107
+ return true;
108
+ }
109
+
110
+ /**
111
+ * Retire la route racine par défaut (`.get("/", ...)`) de la cible et renvoie
112
+ * le texte du handler pour le replacer ailleurs. `null` si absente.
113
+ */
114
+ export function removeRootRoute(
115
+ project: Project,
116
+ target: InjectTarget,
117
+ ): string | null {
118
+ const source = openEntry(project, target.entry);
119
+ if (!source) return null;
120
+
121
+ const getCall = source
122
+ .getDescendantsOfKind(SyntaxKind.CallExpression)
123
+ .find((call) => {
124
+ const expr = call.getExpression();
125
+ if (expr.getKind() !== SyntaxKind.PropertyAccessExpression) return false;
126
+ if ((expr as PropertyAccessExpression).getName() !== "get") return false;
127
+ const first = call.getArguments()[0];
128
+ if (!first || first.getKind() !== SyntaxKind.StringLiteral) return false;
129
+ return first.getText().replace(/['"]/g, "") === "/";
130
+ });
131
+
132
+ if (!getCall) return null;
133
+
134
+ const handler = getCall.getArguments()[1]?.getText() ?? '() => "Hello Elysia"';
135
+ const objText = (getCall.getExpression() as PropertyAccessExpression)
136
+ .getExpression()
137
+ .getText();
138
+ getCall.replaceWithText(objText);
139
+
140
+ source.saveSync();
141
+ log.updated(`${target.entry} (route racine déplacée)`);
142
+ return handler;
143
+ }
144
+
145
+ const CONSOLE_MAP: Record<string, string> = {
146
+ log: "Logger.app.info",
147
+ info: "Logger.app.info",
148
+ debug: "Logger.app.debug",
149
+ warn: "Logger.app.warn",
150
+ error: "Logger.app.error",
151
+ };
152
+
153
+ /** Remplace les `console.*` de la cible par le Logger, et garantit son import. */
154
+ export function replaceConsoleWithLogger(
155
+ project: Project,
156
+ target: InjectTarget,
157
+ loggerPath: string,
158
+ ): number {
159
+ const source = openEntry(project, target.entry);
160
+ if (!source) return 0;
161
+
162
+ let changed = 0;
163
+ for (;;) {
164
+ const pae = source
165
+ .getDescendantsOfKind(SyntaxKind.PropertyAccessExpression)
166
+ .find(
167
+ (pa) =>
168
+ pa.getExpression().getText() === "console" && pa.getName() in CONSOLE_MAP,
169
+ );
170
+ if (!pae) break;
171
+ pae.replaceWithText(CONSOLE_MAP[pae.getName()]);
172
+ changed++;
173
+ if (changed > 200) break; // garde-fou
174
+ }
175
+
176
+ if (changed > 0) {
177
+ ensureImport(source, {
178
+ names: ["Logger"],
179
+ moduleSpecifier: moduleSpecifierFrom(target.entry, loggerPath),
180
+ });
181
+ source.saveSync();
182
+ log.updated(`${target.entry} (console.* → Logger ×${changed})`);
183
+ }
184
+ return changed;
185
+ }
186
+
187
+ /** Specifier d'import relatif depuis `fromEntry` vers un fichier cible. */
188
+ export function moduleSpecifierFrom(fromEntry: string, targetPath: string): string {
189
+ const fromAbs = resolve(process.cwd(), fromEntry);
190
+ const rel = relative(dirname(fromAbs), resolve(process.cwd(), targetPath))
191
+ .replace(/\\/g, "/")
192
+ .replace(/\.ts$/, "");
193
+ return rel.startsWith(".") ? rel : `./${rel}`;
194
+ }
195
+
196
+ // ---------------------------------------------------------------------------
197
+
198
+ function openEntry(project: Project, entry: string): SourceFile | null {
199
+ const abs = resolve(process.cwd(), entry);
200
+ if (!existsSync(abs)) {
201
+ log.warn(`Fichier cible introuvable (${entry}) — injection ignorée.`);
202
+ return null;
203
+ }
204
+ return (
205
+ project.addSourceFileAtPathIfExists(abs) ?? project.addSourceFileAtPath(abs)
206
+ );
207
+ }
208
+
209
+ function ensureImport(source: SourceFile, spec: ImportSpec): void {
210
+ const existing = source
211
+ .getImportDeclarations()
212
+ .find((d) => d.getModuleSpecifierValue() === spec.moduleSpecifier);
213
+ if (!existing) {
214
+ source.addImportDeclaration({
215
+ moduleSpecifier: spec.moduleSpecifier,
216
+ namedImports: spec.names,
217
+ });
218
+ return;
219
+ }
220
+ const present = new Set(existing.getNamedImports().map((n) => n.getName()));
221
+ for (const name of spec.names) {
222
+ if (!present.has(name)) existing.addNamedImport(name);
223
+ }
224
+ }
225
+
226
+ function findElysiaInstance(
227
+ source: SourceFile,
228
+ variable: string,
229
+ ): NewExpression | undefined {
230
+ const decl = source.getVariableDeclaration(variable);
231
+ if (decl) {
232
+ const fromDecl = decl
233
+ .getDescendantsOfKind(SyntaxKind.NewExpression)
234
+ .find(isElysia);
235
+ if (fromDecl) return fromDecl;
236
+ }
237
+ return source.getDescendantsOfKind(SyntaxKind.NewExpression).find(isElysia);
238
+ }
239
+
240
+ function isElysia(node: NewExpression): boolean {
241
+ return node.getExpression().getText() === "Elysia";
242
+ }
@@ -0,0 +1,21 @@
1
+ import chalk from "chalk";
2
+
3
+ /** Petits wrappers de sortie colorée, centralisés pour rester cohérents. */
4
+ export const log = {
5
+ info: (msg: string) => console.log(chalk.cyan("ℹ"), msg),
6
+ success: (msg: string) => console.log(chalk.green("✔"), msg),
7
+ warn: (msg: string) => console.log(chalk.yellow("⚠"), msg),
8
+ error: (msg: string) => console.error(chalk.red("✖"), msg),
9
+ /** Affiche un fichier créé. */
10
+ created: (path: string) =>
11
+ console.log(` ${chalk.green("+")} ${chalk.dim(path)}`),
12
+ /** Affiche un fichier modifié (injection). */
13
+ updated: (path: string) =>
14
+ console.log(` ${chalk.yellow("~")} ${chalk.dim(path)}`),
15
+ /** Affiche un fichier ignoré (déjà présent). */
16
+ skipped: (path: string) =>
17
+ console.log(` ${chalk.gray("=")} ${chalk.dim(path + " (existe déjà)")}`),
18
+ title: (msg: string) => console.log("\n" + chalk.bold(msg)),
19
+ };
20
+
21
+ export class CliError extends Error {}
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Helpers de casse pour dériver les différentes formes d'un nom de module.
3
+ * Ex: "user-profile" ->
4
+ * kebab: user-profile
5
+ * pascal: UserProfile
6
+ * camel: userProfile
7
+ * plural: userProfiles
8
+ */
9
+
10
+ import { CliError } from "./logger.js";
11
+
12
+ /**
13
+ * Noms autorisés : commence par une lettre, puis lettres/chiffres/. _ -.
14
+ * Bloque les caractères qui permettraient une injection shell (le nom passe
15
+ * dans `bun create elysia <name>`) ou un path traversal (dossiers de modules).
16
+ */
17
+ const NAME_RE = /^[a-zA-Z][a-zA-Z0-9._-]*$/;
18
+
19
+ export function nameError(input: string): string | undefined {
20
+ if (!input.trim()) return "Nom requis";
21
+ if (input.length > 100) return "Nom trop long (max 100).";
22
+ if (!NAME_RE.test(input)) {
23
+ return "Autorisé : lettres, chiffres, . _ - (doit commencer par une lettre).";
24
+ }
25
+ return undefined;
26
+ }
27
+
28
+ /** Lève si le nom est invalide (à utiliser sur les noms passés en argument). */
29
+ export function assertValidName(input: string): void {
30
+ const err = nameError(input);
31
+ if (err) throw new CliError(`Nom invalide "${input}" : ${err}`);
32
+ }
33
+
34
+ function splitWords(input: string): string[] {
35
+ return input
36
+ // sépare camelCase / PascalCase
37
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
38
+ // remplace séparateurs par des espaces
39
+ .replace(/[-_/\s]+/g, " ")
40
+ .trim()
41
+ .toLowerCase()
42
+ .split(" ")
43
+ .filter(Boolean);
44
+ }
45
+
46
+ export function toKebab(input: string): string {
47
+ return splitWords(input).join("-");
48
+ }
49
+
50
+ export function toPascal(input: string): string {
51
+ return splitWords(input)
52
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
53
+ .join("");
54
+ }
55
+
56
+ export function toCamel(input: string): string {
57
+ const pascal = toPascal(input);
58
+ return pascal.charAt(0).toLowerCase() + pascal.slice(1);
59
+ }
60
+
61
+ /** Pluriel naïf en anglais — suffisant pour les chemins de route. */
62
+ export function toPlural(word: string): string {
63
+ if (/[^aeiou]y$/i.test(word)) return word.replace(/y$/i, "ies");
64
+ if (/(s|sh|ch|x|z)$/i.test(word)) return word + "es";
65
+ return word + "s";
66
+ }
67
+
68
+ export interface NameSet {
69
+ raw: string;
70
+ kebab: string;
71
+ pascal: string;
72
+ camel: string;
73
+ camelPlural: string;
74
+ kebabPlural: string;
75
+ }
76
+
77
+ export function buildNames(input: string): NameSet {
78
+ const kebab = toKebab(input);
79
+ const camel = toCamel(input);
80
+ return {
81
+ raw: input,
82
+ kebab,
83
+ pascal: toPascal(input),
84
+ camel,
85
+ camelPlural: toPlural(camel),
86
+ kebabPlural: toPlural(kebab),
87
+ };
88
+ }
@@ -0,0 +1,32 @@
1
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { log } from "./logger.js";
4
+
5
+ /**
6
+ * Ajoute des scripts au package.json du projet courant sans écraser ceux qui
7
+ * existent déjà. Conserve l'indentation à 2 espaces.
8
+ */
9
+ export function ensureScripts(scripts: Record<string, string>): void {
10
+ const path = join(process.cwd(), "package.json");
11
+ if (!existsSync(path)) {
12
+ log.warn("package.json introuvable — scripts non ajoutés.");
13
+ return;
14
+ }
15
+
16
+ const pkg = JSON.parse(readFileSync(path, "utf8")) as {
17
+ scripts?: Record<string, string>;
18
+ };
19
+ pkg.scripts ??= {};
20
+
21
+ const added: string[] = [];
22
+ for (const [name, cmd] of Object.entries(scripts)) {
23
+ if (pkg.scripts[name] === undefined) {
24
+ pkg.scripts[name] = cmd;
25
+ added.push(name);
26
+ }
27
+ }
28
+ if (!added.length) return;
29
+
30
+ writeFileSync(path, JSON.stringify(pkg, null, 2) + "\n", "utf8");
31
+ log.updated(`package.json (scripts : ${added.join(", ")})`);
32
+ }
@@ -0,0 +1,39 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { log, CliError } from "./logger.js";
3
+
4
+ /**
5
+ * Installe des dépendances dans le projet courant via `bun add`.
6
+ * Dédoublonne, ignore la liste vide, et remonte une erreur si l'install échoue.
7
+ */
8
+ export function installDeps(pkgs: string[], opts: { dev?: boolean } = {}): void {
9
+ const unique = [...new Set(pkgs)].filter(Boolean);
10
+ if (!unique.length) return;
11
+
12
+ const devFlag = opts.dev ? ["-d"] : [];
13
+ log.info(`Installation : bun add ${devFlag.join(" ")} ${unique.join(" ")}`.trim());
14
+ const res = spawnSync("bun", ["add", ...devFlag, ...unique], {
15
+ stdio: "inherit",
16
+ shell: true,
17
+ });
18
+ if (res.error) {
19
+ throw new CliError("Impossible de lancer `bun add` (Bun est-il installé ?).");
20
+ }
21
+ if (res.status !== 0) {
22
+ throw new CliError("`bun add` a échoué.");
23
+ }
24
+ }
25
+
26
+ /**
27
+ * Exécute une commande best-effort : logue, ne jette pas si elle échoue
28
+ * (utile pour les migrations qui nécessitent une base de données joignable).
29
+ * Retourne `true` si la commande a réussi.
30
+ */
31
+ export function run(label: string, cmd: string, args: string[]): boolean {
32
+ log.info(`${label} : ${cmd} ${args.join(" ")}`);
33
+ const res = spawnSync(cmd, args, { stdio: "inherit", shell: true });
34
+ if (res.error || res.status !== 0) {
35
+ log.warn(`Échec de "${label}" — à relancer manuellement.`);
36
+ return false;
37
+ }
38
+ return true;
39
+ }
@@ -0,0 +1,62 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
+ import { Project } from "ts-morph";
4
+ import { CliError } from "./logger.js";
5
+
6
+ export interface ElysiaCliConfig {
7
+ /** Racine du code source. */
8
+ srcDir: string;
9
+ /** Dossier où sont générés les modules. */
10
+ modulesDir: string;
11
+ /** Fichier d'entrée qui instancie l'app Elysia. */
12
+ appEntry: string;
13
+ /** Nom de la variable Elysia dans l'app entry (pour l'injection du .use()). */
14
+ appVariable: string;
15
+ /** Fichier agrégateur des modules (où `generate module` branche les controllers). */
16
+ modulesEntry: string;
17
+ /** Nom de la variable Elysia dans l'agrégateur des modules. */
18
+ modulesVariable: string;
19
+ }
20
+
21
+ const DEFAULT_CONFIG: ElysiaCliConfig = {
22
+ srcDir: "src",
23
+ modulesDir: "src/modules",
24
+ appEntry: "src/index.ts",
25
+ appVariable: "app",
26
+ modulesEntry: "src/modules/index.ts",
27
+ modulesVariable: "modules",
28
+ };
29
+
30
+ const CONFIG_FILE = "elysia-cli.config.json";
31
+
32
+ /** Résout la config du projet cible (fichier optionnel + defaults). */
33
+ export function loadConfig(cwd = process.cwd()): ElysiaCliConfig {
34
+ const configPath = join(cwd, CONFIG_FILE);
35
+ if (!existsSync(configPath)) return DEFAULT_CONFIG;
36
+ try {
37
+ const raw = JSON.parse(readFileSync(configPath, "utf8"));
38
+ return { ...DEFAULT_CONFIG, ...raw };
39
+ } catch (err) {
40
+ throw new CliError(
41
+ `Impossible de lire ${CONFIG_FILE}: ${(err as Error).message}`,
42
+ );
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Charge un Project ts-morph pointant sur le tsconfig du projet cible.
48
+ * Utilisé pour les opérations d'injection (add / register de plugin).
49
+ */
50
+ export function loadTsProject(cwd = process.cwd()): Project {
51
+ const tsconfigPath = join(cwd, "tsconfig.json");
52
+ if (existsSync(tsconfigPath)) {
53
+ return new Project({ tsConfigFilePath: tsconfigPath });
54
+ }
55
+ // Fallback sans tsconfig : projet en mémoire, on ajoute les fichiers à la demande.
56
+ return new Project({ skipAddingFilesFromTsConfig: true });
57
+ }
58
+
59
+ /** Chemin absolu vers le dossier d'un module. */
60
+ export function moduleDir(config: ElysiaCliConfig, kebabName: string): string {
61
+ return resolve(process.cwd(), config.modulesDir, kebabName);
62
+ }