@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,173 @@
1
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
2
+ import { join, relative, resolve, dirname } from "node:path";
3
+ import type { Command } from "commander";
4
+ import * as p from "@clack/prompts";
5
+ import type { Project } from "ts-morph";
6
+ import {
7
+ buildNames,
8
+ nameError,
9
+ assertValidName,
10
+ type NameSet,
11
+ } from "../utils/naming.js";
12
+ import {
13
+ loadConfig,
14
+ loadTsProject,
15
+ moduleDir,
16
+ type ElysiaCliConfig,
17
+ } from "../utils/project.js";
18
+ import { registerUse, moduleSpecifierFrom } from "../utils/inject.js";
19
+ import { log, CliError } from "../utils/logger.js";
20
+ import {
21
+ controllerTemplate,
22
+ serviceTemplate,
23
+ modelTemplate,
24
+ barrelTemplate,
25
+ modulesIndexTemplate,
26
+ } from "../templates.js";
27
+
28
+ type Kind = "module" | "service" | "model";
29
+ const KINDS: Kind[] = ["module", "service", "model"];
30
+
31
+ export function registerGenerateCommand(program: Command) {
32
+ program
33
+ .command("generate")
34
+ .alias("g")
35
+ .description("Génère un module, service ou model Elysia")
36
+ .argument("[kind]", "module | service | model")
37
+ .argument("[name]", "nom (ex: user, user-profile)")
38
+ .option("--no-register", "ne pas injecter le .use() dans l'app entry")
39
+ .action(async (kind?: string, name?: string, opts?: { register: boolean }) => {
40
+ const resolved = await resolveArgs(kind, name);
41
+ const names = buildNames(resolved.name);
42
+ const config = loadConfig();
43
+
44
+ switch (resolved.kind) {
45
+ case "module":
46
+ generateModule(names, config, opts?.register ?? true);
47
+ break;
48
+ case "service":
49
+ writeSingle(config, names, "service", serviceTemplate(names));
50
+ break;
51
+ case "model":
52
+ writeSingle(config, names, "model", modelTemplate(names));
53
+ break;
54
+ }
55
+ });
56
+ }
57
+
58
+ async function resolveArgs(
59
+ kind?: string,
60
+ name?: string,
61
+ ): Promise<{ kind: Kind; name: string }> {
62
+ let k = kind as Kind | undefined;
63
+ if (!k) {
64
+ const answer = await p.select({
65
+ message: "Que veux-tu générer ?",
66
+ options: KINDS.map((v) => ({ value: v, label: v })),
67
+ });
68
+ if (p.isCancel(answer)) throw new CliError("Annulé.");
69
+ k = answer as Kind;
70
+ }
71
+ if (!KINDS.includes(k)) {
72
+ throw new CliError(`Type inconnu "${k}". Attendu : ${KINDS.join(", ")}.`);
73
+ }
74
+
75
+ let n = name;
76
+ if (!n) {
77
+ const answer = await p.text({
78
+ message: `Nom du ${k} ?`,
79
+ placeholder: "user",
80
+ validate: nameError,
81
+ });
82
+ if (p.isCancel(answer)) throw new CliError("Annulé.");
83
+ n = answer as string;
84
+ }
85
+ assertValidName(n);
86
+ return { kind: k, name: n };
87
+ }
88
+
89
+ function generateModule(names: NameSet, config: ReturnType<typeof loadConfig>, register: boolean) {
90
+ const dir = moduleDir(config, names.kebab);
91
+ log.title(`Génération du module ${names.pascal}`);
92
+
93
+ const files: Array<[string, string]> = [
94
+ [`${names.kebab}.controller.ts`, controllerTemplate(names)],
95
+ [`${names.kebab}.service.ts`, serviceTemplate(names)],
96
+ [`${names.kebab}.model.ts`, modelTemplate(names)],
97
+ ["index.ts", barrelTemplate(names)],
98
+ ];
99
+
100
+ mkdirSync(dir, { recursive: true });
101
+ for (const [file, content] of files) {
102
+ const full = join(dir, file);
103
+ if (existsSync(full)) {
104
+ log.skipped(relative(process.cwd(), full));
105
+ continue;
106
+ }
107
+ writeFileSync(full, content, "utf8");
108
+ log.created(relative(process.cwd(), full));
109
+ }
110
+
111
+ if (register) {
112
+ const project = loadTsProject();
113
+ ensureModulesAggregator(project, config);
114
+
115
+ const importPath = join(config.modulesDir, names.kebab); // dossier => index barrel
116
+ const controllerVar = names.camel;
117
+ registerUse(
118
+ project,
119
+ { entry: config.modulesEntry, variable: config.modulesVariable },
120
+ {
121
+ useExpr: controllerVar,
122
+ ensureImport: {
123
+ names: [controllerVar],
124
+ moduleSpecifier: moduleSpecifierFrom(config.modulesEntry, importPath),
125
+ },
126
+ },
127
+ );
128
+ }
129
+
130
+ log.success(`Module ${names.pascal} prêt.`);
131
+ }
132
+
133
+ function writeSingle(
134
+ config: ReturnType<typeof loadConfig>,
135
+ names: NameSet,
136
+ kind: "service" | "model",
137
+ content: string,
138
+ ) {
139
+ const dir = moduleDir(config, names.kebab);
140
+ mkdirSync(dir, { recursive: true });
141
+ const full = join(dir, `${names.kebab}.${kind}.ts`);
142
+ if (existsSync(full)) {
143
+ throw new CliError(`${relative(process.cwd(), full)} existe déjà.`);
144
+ }
145
+ writeFileSync(full, content, "utf8");
146
+ log.created(relative(process.cwd(), full));
147
+ log.success(`${kind} ${names.pascal} généré.`);
148
+ }
149
+
150
+ /**
151
+ * Garantit l'existence de l'agrégateur `src/modules/index.ts` : s'il manque, le
152
+ * crée (instance `modules` vide) et le branche dans l'app entry via `.use(modules)`.
153
+ */
154
+ function ensureModulesAggregator(project: Project, config: ElysiaCliConfig): void {
155
+ const abs = resolve(process.cwd(), config.modulesEntry);
156
+ if (existsSync(abs)) return;
157
+
158
+ mkdirSync(dirname(abs), { recursive: true });
159
+ writeFileSync(abs, modulesIndexTemplate(), "utf8");
160
+ log.created(relative(process.cwd(), abs));
161
+
162
+ registerUse(
163
+ project,
164
+ { entry: config.appEntry, variable: config.appVariable },
165
+ {
166
+ useExpr: config.modulesVariable,
167
+ ensureImport: {
168
+ names: [config.modulesVariable],
169
+ moduleSpecifier: moduleSpecifierFrom(config.appEntry, config.modulesDir),
170
+ },
171
+ },
172
+ );
173
+ }
@@ -0,0 +1,158 @@
1
+ import { existsSync, mkdirSync, writeFileSync, readdirSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { spawnSync } from "node:child_process";
4
+ import type { Command } from "commander";
5
+ import * as p from "@clack/prompts";
6
+ import { loadTsProject, type ElysiaCliConfig } from "../utils/project.js";
7
+ import {
8
+ removeRootRoute,
9
+ registerUse,
10
+ moduleSpecifierFrom,
11
+ } from "../utils/inject.js";
12
+ import { nameError, assertValidName } from "../utils/naming.js";
13
+ import { modulesIndexTemplate } from "../templates.js";
14
+ import { applyPlugin, PLUGIN_KEYS } from "./add.js";
15
+ import { installDeps } from "../utils/pm.js";
16
+ import { log, CliError } from "../utils/logger.js";
17
+
18
+ /**
19
+ * `init <project>` — délègue le scaffolding à `bun create elysia`, puis :
20
+ * - pose la config elysia-cli + le dossier modules,
21
+ * - déplace la route racine par défaut dans src/modules/index.ts,
22
+ * - propose d'ajouter des plugins directement.
23
+ */
24
+ export function registerInitCommand(program: Command) {
25
+ program
26
+ .command("init")
27
+ .description("Bootstrap d'un projet Elysia (via `bun create elysia`)")
28
+ .argument("[project]", "nom du dossier projet")
29
+ .option(
30
+ "--skip-scaffold",
31
+ "ne pas lancer `bun create elysia` (dossier déjà existant)",
32
+ )
33
+ .action(async (project?: string, opts?: { skipScaffold?: boolean }) => {
34
+ let name = project;
35
+ if (!name) {
36
+ const answer = await p.text({
37
+ message: "Nom du projet ?",
38
+ placeholder: "my-api",
39
+ validate: nameError,
40
+ });
41
+ if (p.isCancel(answer)) throw new CliError("Annulé.");
42
+ name = answer as string;
43
+ }
44
+ assertValidName(name);
45
+
46
+ const root = join(process.cwd(), name);
47
+
48
+ // 1. Scaffolding officiel Elysia (OK si dossier inexistant ou vide).
49
+ if (!opts?.skipScaffold) {
50
+ if (existsSync(root) && !isEmptyDir(root)) {
51
+ throw new CliError(
52
+ `Le dossier ${name} existe déjà et n'est pas vide ` +
53
+ `(utilise --skip-scaffold pour l'enrichir).`,
54
+ );
55
+ }
56
+ log.title(`Scaffolding : bun create elysia ${name}`);
57
+ const res = spawnSync("bun", ["create", "elysia", name], {
58
+ stdio: "inherit",
59
+ cwd: process.cwd(),
60
+ shell: true,
61
+ });
62
+ if (res.error) {
63
+ throw new CliError(
64
+ "Impossible de lancer `bun`. Bun est-il installé ? (bun --version)",
65
+ );
66
+ }
67
+ if (res.status !== 0) throw new CliError("`bun create elysia` a échoué.");
68
+ } else if (!existsSync(root)) {
69
+ throw new CliError(`Le dossier ${name} n'existe pas — retire --skip-scaffold.`);
70
+ }
71
+
72
+ // À partir d'ici on travaille DANS le projet.
73
+ process.chdir(root);
74
+
75
+ // 2. Config elysia-cli + dossier modules.
76
+ log.title("Configuration elysia-cli");
77
+ const config: ElysiaCliConfig = {
78
+ srcDir: "src",
79
+ modulesDir: "src/modules",
80
+ appEntry: "src/index.ts",
81
+ appVariable: "app",
82
+ modulesEntry: "src/modules/index.ts",
83
+ modulesVariable: "modules",
84
+ };
85
+ mkdirSync(config.modulesDir, { recursive: true });
86
+
87
+ const configPath = "elysia-cli.config.json";
88
+ if (existsSync(configPath)) {
89
+ log.skipped(configPath);
90
+ } else {
91
+ writeFileSync(configPath, JSON.stringify(config, null, 2), "utf8");
92
+ log.created(configPath);
93
+ }
94
+
95
+ // 3. Déplace la route racine par défaut dans src/modules/index.ts.
96
+ relocateRootRoute(config);
97
+
98
+ // 4. Plugins à ajouter directement.
99
+ const selected = await p.multiselect({
100
+ message: "Plugins à ajouter maintenant ? (espace pour sélectionner)",
101
+ options: PLUGIN_KEYS.map((k) => ({ value: k, label: k })),
102
+ required: false,
103
+ });
104
+ if (!p.isCancel(selected) && Array.isArray(selected) && selected.length) {
105
+ log.title("Ajout des plugins");
106
+ // Ordre canonique (prisma avant better-auth pour l'adapter).
107
+ const chosen = PLUGIN_KEYS.filter((k) => (selected as string[]).includes(k));
108
+ const pkgs = new Set<string>();
109
+ const devPkgs = new Set<string>();
110
+ const posts: Array<() => void> = [];
111
+ for (const key of chosen) {
112
+ const res = await applyPlugin(key, config);
113
+ res.pkgs.forEach((x) => pkgs.add(x));
114
+ res.devPkgs.forEach((x) => devPkgs.add(x));
115
+ if (res.postInstall) posts.push(res.postInstall);
116
+ }
117
+ installDeps([...pkgs]);
118
+ installDeps([...devPkgs], { dev: true });
119
+ for (const fn of posts) fn();
120
+ }
121
+
122
+ log.success(`Projet ${name} prêt.`);
123
+ log.info(`cd ${name} && bun dev`);
124
+ });
125
+ }
126
+
127
+ /** Décroche la route `.get("/")` de l'app entry et la place dans le module racine. */
128
+ function relocateRootRoute(config: ElysiaCliConfig): void {
129
+ const project = loadTsProject();
130
+ const app = { entry: config.appEntry, variable: config.appVariable };
131
+ const handler = removeRootRoute(project, app);
132
+
133
+ if (!existsSync(config.modulesEntry)) {
134
+ mkdirSync(config.modulesDir, { recursive: true });
135
+ writeFileSync(
136
+ config.modulesEntry,
137
+ modulesIndexTemplate(handler ?? '() => "Hello Elysia"'),
138
+ "utf8",
139
+ );
140
+ log.created(config.modulesEntry);
141
+ } else {
142
+ log.skipped(config.modulesEntry);
143
+ }
144
+
145
+ registerUse(project, app, {
146
+ useExpr: config.modulesVariable,
147
+ ensureImport: {
148
+ names: [config.modulesVariable],
149
+ moduleSpecifier: moduleSpecifierFrom(config.appEntry, config.modulesDir),
150
+ },
151
+ });
152
+ }
153
+
154
+ /** Un dossier est considéré « vide » s'il ne contient que des fichiers bruit. */
155
+ function isEmptyDir(dir: string): boolean {
156
+ const IGNORED = new Set([".git", ".DS_Store", "Thumbs.db", ".keep", ".gitkeep"]);
157
+ return readdirSync(dir).every((entry) => IGNORED.has(entry));
158
+ }
@@ -0,0 +1,68 @@
1
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import type { Command } from "commander";
4
+ import * as p from "@clack/prompts";
5
+ import { buildNames, nameError, assertValidName } from "../utils/naming.js";
6
+ import { loadConfig, loadTsProject } from "../utils/project.js";
7
+ import { registerUse, moduleSpecifierFrom } from "../utils/inject.js";
8
+ import { macroTemplate } from "../templates.js";
9
+ import { log, CliError } from "../utils/logger.js";
10
+
11
+ /**
12
+ * `macro <name>` — génère une macro Elysia dans `src/macros/<name>.macro.ts`
13
+ * et la branche dans l'app entry via `.use(<name>Macro)`.
14
+ */
15
+ export function registerMacroCommand(program: Command) {
16
+ program
17
+ .command("macro")
18
+ .description("Génère une macro Elysia et la branche dans l'app")
19
+ .argument("[name]", "nom de la macro (ex: auth, roles)")
20
+ .option("--no-register", "ne pas injecter le .use() dans l'app entry")
21
+ .action(async (name?: string, opts?: { register: boolean }) => {
22
+ let n = name;
23
+ if (!n) {
24
+ const answer = await p.text({
25
+ message: "Nom de la macro ?",
26
+ placeholder: "auth",
27
+ validate: nameError,
28
+ });
29
+ if (p.isCancel(answer)) throw new CliError("Annulé.");
30
+ n = answer as string;
31
+ }
32
+ assertValidName(n);
33
+
34
+ const names = buildNames(n);
35
+ const config = loadConfig();
36
+
37
+ const rel = join(config.srcDir, "macros", `${names.kebab}.macro.ts`);
38
+ const target = join(process.cwd(), rel);
39
+ if (existsSync(target)) {
40
+ log.skipped(rel);
41
+ } else {
42
+ mkdirSync(join(process.cwd(), config.srcDir, "macros"), { recursive: true });
43
+ writeFileSync(target, macroTemplate(names), "utf8");
44
+ log.created(rel);
45
+ }
46
+
47
+ if (opts?.register ?? true) {
48
+ const project = loadTsProject();
49
+ const macroVar = `${names.camel}Macro`;
50
+ registerUse(
51
+ project,
52
+ { entry: config.appEntry, variable: config.appVariable },
53
+ {
54
+ useExpr: macroVar,
55
+ ensureImport: {
56
+ names: [macroVar],
57
+ moduleSpecifier: moduleSpecifierFrom(
58
+ config.appEntry,
59
+ join(config.srcDir, "macros", `${names.kebab}.macro`),
60
+ ),
61
+ },
62
+ },
63
+ );
64
+ }
65
+
66
+ log.success(`Macro ${names.camel} générée.`);
67
+ });
68
+ }
package/src/index.ts ADDED
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env bun
2
+ import { Command } from "commander";
3
+ import chalk from "chalk";
4
+ import { log, CliError } from "./utils/logger.js";
5
+ import { registerGenerateCommand } from "./commands/generate.js";
6
+ import { registerAddCommand } from "./commands/add.js";
7
+ import { registerInitCommand } from "./commands/init.js";
8
+ import { registerMacroCommand } from "./commands/macro.js";
9
+ import pkg from "../package.json" with { type: "json" };
10
+
11
+ const program = new Command();
12
+
13
+ program
14
+ .name("elysia-cli")
15
+ .description("Scaffolding pour projets ElysiaJS")
16
+ .version(pkg.version);
17
+
18
+ registerGenerateCommand(program);
19
+ registerAddCommand(program);
20
+ registerInitCommand(program);
21
+ registerMacroCommand(program);
22
+
23
+ async function main() {
24
+ try {
25
+ await program.parseAsync(process.argv);
26
+ } catch (err) {
27
+ if (err instanceof CliError) {
28
+ log.error(err.message);
29
+ } else {
30
+ log.error("Erreur inattendue :");
31
+ console.error(chalk.red(String((err as Error)?.stack ?? err)));
32
+ }
33
+ process.exit(1);
34
+ }
35
+ }
36
+
37
+ main();