@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,452 @@
1
+ import type { NameSet } from "./utils/naming.js";
2
+
3
+ /**
4
+ * Fonctions de génération de contenu de fichiers.
5
+ * On garde des template strings simples plutôt que Handlebars : moins de deps,
6
+ * typage complet, et facile à faire évoluer.
7
+ */
8
+
9
+ /**
10
+ * Controller = une instance Elysia (routes), nommée d'après le module.
11
+ * Importe le Service (logique) et le Model (validation body/response).
12
+ */
13
+ export function controllerTemplate(n: NameSet): string {
14
+ return `import { Elysia, t } from "elysia";
15
+
16
+ import { ${n.pascal} } from "./${n.kebab}.service";
17
+ import { ${n.pascal}Model } from "./${n.kebab}.model";
18
+
19
+ export const ${n.camel} = new Elysia({ prefix: "/${n.kebabPlural}" })
20
+ .get("/", () => ${n.pascal}.findAll())
21
+ .get(
22
+ "/:id",
23
+ ({ params: { id } }) => ${n.pascal}.findOne(id),
24
+ { params: t.Object({ id: t.String() }) },
25
+ )
26
+ .post(
27
+ "/",
28
+ ({ body }) => ${n.pascal}.create(body),
29
+ {
30
+ body: ${n.pascal}Model.create,
31
+ // response optionnelle : contractualise le type de retour
32
+ response: { 200: ${n.pascal}Model.entity },
33
+ },
34
+ )
35
+ .put(
36
+ "/:id",
37
+ ({ params: { id }, body }) => ${n.pascal}.update(id, body),
38
+ { params: t.Object({ id: t.String() }), body: ${n.pascal}Model.update },
39
+ )
40
+ .delete(
41
+ "/:id",
42
+ ({ params: { id } }) => ${n.pascal}.remove(id),
43
+ { params: t.Object({ id: t.String() }) },
44
+ );
45
+ `;
46
+ }
47
+
48
+ /** Service = classe abstraite avec méthodes statiques (logique métier). */
49
+ export function serviceTemplate(n: NameSet): string {
50
+ return `import type { ${n.pascal}Model } from "./${n.kebab}.model";
51
+
52
+ /**
53
+ * Service ${n.pascal} — logique métier, indépendante d'une requête.
54
+ * Classe abstraite + méthodes statiques (bonne pratique Elysia pour un
55
+ * service sans état). Remplace le stub in-memory par ta couche data.
56
+ */
57
+ export abstract class ${n.pascal} {
58
+ static async findAll(): Promise<${n.pascal}Model["entity"][]> {
59
+ return [];
60
+ }
61
+
62
+ static async findOne(id: string) {
63
+ return { id, name: "" };
64
+ }
65
+
66
+ static async create(body: ${n.pascal}Model["create"]) {
67
+ return { id: crypto.randomUUID(), ...body };
68
+ }
69
+
70
+ static async update(id: string, body: ${n.pascal}Model["update"]) {
71
+ return { id, ...body };
72
+ }
73
+
74
+ static async remove(id: string) {
75
+ return { id, deleted: true };
76
+ }
77
+ }
78
+ `;
79
+ }
80
+
81
+ /** Model = objet de schémas t.* + type TS dérivé (source unique de vérité). */
82
+ export function modelTemplate(n: NameSet): string {
83
+ return `// Model define the data structure and validation for the request and response
84
+ import { t, type UnwrapSchema } from "elysia";
85
+
86
+ export const ${n.pascal}Model = {
87
+ entity: t.Object({
88
+ id: t.String(),
89
+ name: t.String(),
90
+ }),
91
+ create: t.Object({
92
+ name: t.String({ minLength: 1 }),
93
+ }),
94
+ update: t.Partial(
95
+ t.Object({
96
+ name: t.String({ minLength: 1 }),
97
+ }),
98
+ ),
99
+ } as const;
100
+
101
+ // Optional, cast all model to TypeScript type
102
+ export type ${n.pascal}Model = {
103
+ [K in keyof typeof ${n.pascal}Model]: UnwrapSchema<(typeof ${n.pascal}Model)[K]>;
104
+ };
105
+ `;
106
+ }
107
+
108
+ /** Barrel export du module. */
109
+ export function barrelTemplate(n: NameSet): string {
110
+ return `export * from "./${n.kebab}.controller";
111
+ export * from "./${n.kebab}.service";
112
+ export * from "./${n.kebab}.model";
113
+ `;
114
+ }
115
+
116
+ /** Macro générique (`src/macros/<name>.macro.ts`). */
117
+ export function macroTemplate(n: NameSet): string {
118
+ return `import { Elysia } from "elysia";
119
+
120
+ /**
121
+ * Macro ${n.camel} — activable par route via \`{ ${n.camel}: true }\`.
122
+ * \`resolve\` peut retourner des valeurs injectées dans le contexte des routes.
123
+ */
124
+ export const ${n.camel}Macro = new Elysia({ name: "${n.kebab}.macro" }).macro({
125
+ ${n.camel}: {
126
+ resolve() {
127
+ // TODO: logique de la macro (auth, permissions, contexte…)
128
+ return {};
129
+ },
130
+ },
131
+ });
132
+ `;
133
+ }
134
+
135
+ /**
136
+ * Macro `auth` liée à better-auth (`src/macros/auth.macro.ts`).
137
+ * Monte le handler et expose `user`/`session` aux routes protégées.
138
+ */
139
+ export function authMacroTemplate(): string {
140
+ return `import { Elysia } from "elysia";
141
+ import { auth } from "../lib/auth";
142
+
143
+ /**
144
+ * Plugin better-auth : monte le handler ET expose la macro \`auth\`.
145
+ * Usage : \`.get("/me", ({ user }) => user, { auth: true })\`
146
+ */
147
+ export const authMacro = new Elysia({ name: "better-auth" })
148
+ .mount(auth.handler)
149
+ .macro({
150
+ auth: {
151
+ async resolve({ status, request: { headers } }) {
152
+ const session = await auth.api.getSession({ headers });
153
+ if (!session) return status(401);
154
+
155
+ return {
156
+ user: session.user,
157
+ session: session.session,
158
+ };
159
+ },
160
+ },
161
+ });
162
+ `;
163
+ }
164
+
165
+ /**
166
+ * Module racine (`src/modules/index.ts`) — point d'agrégation des modules.
167
+ * `generate module` y branche chaque controller. Accueille éventuellement la
168
+ * route par défaut déplacée depuis l'app entry.
169
+ */
170
+ export function modulesIndexTemplate(rootHandler?: string): string {
171
+ const route = rootHandler ? `\n .get("/", ${rootHandler})` : "";
172
+ return `import { Elysia } from "elysia";
173
+
174
+ /**
175
+ * Agrégat des modules de l'application. Les controllers sont branchés ici via
176
+ * \`.use(...)\` par \`generate module\`.
177
+ */
178
+ export const modules = new Elysia({ name: "modules" })${route};
179
+ `;
180
+ }
181
+
182
+ /**
183
+ * Hooks de logging des requêtes, injectés directement dans la chaîne de l'app
184
+ * entry (après `new Elysia()`). Nécessite l'import de `Logger`.
185
+ */
186
+ export function loggerHooksSegment(): string {
187
+ return `.onBeforeHandle((ctx) => {
188
+ (ctx as any)._startTime = Date.now();
189
+ })
190
+ .onAfterResponse(({ request, set, server, ...ctx }) => {
191
+ const status = set.status;
192
+ const ip = server?.requestIP(request)?.address;
193
+ const duration = Date.now() - ((ctx as any)._startTime ?? Date.now());
194
+ Logger.app.request(request.method, request.url, status, ip, \`\${duration}ms\`);
195
+ })`;
196
+ }
197
+
198
+ /**
199
+ * Logger applicatif (chalk) — écrit dans le projet cible par `add logger`.
200
+ * Nécessite \`chalk\` et \`elysia\` (déjà présent).
201
+ */
202
+ export function loggerTemplate(): string {
203
+ return `import chalk from "chalk";
204
+ import { StatusMap } from "elysia";
205
+
206
+ export type LogLevel = "debug" | "info" | "warn" | "error" | "silent";
207
+
208
+ const LOG_LEVELS: Record<LogLevel, number> = {
209
+ debug: 0,
210
+ info: 1,
211
+ warn: 2,
212
+ error: 3,
213
+ silent: 4,
214
+ };
215
+
216
+ const currentLevel: LogLevel =
217
+ (process.env.LOG_LEVEL as LogLevel) ??
218
+ (process.env.NODE_ENV === "production" ? "info" : "debug");
219
+
220
+ function shouldLog(level: LogLevel): boolean {
221
+ return LOG_LEVELS[level] >= LOG_LEVELS[currentLevel];
222
+ }
223
+
224
+ function messageParser(message: unknown): string {
225
+ if (typeof message !== "string") return JSON.stringify(message);
226
+ return message;
227
+ }
228
+
229
+ function formatMeta(meta?: object): string {
230
+ if (!meta) return "";
231
+ return " " + chalk.dim(JSON.stringify(meta));
232
+ }
233
+
234
+ function formatTime(): string {
235
+ const d = new Date();
236
+ const pad = (n: number) => String(n).padStart(2, "0");
237
+ return \`\${pad(d.getHours())}:\${pad(d.getMinutes())}:\${pad(d.getSeconds())}\`;
238
+ }
239
+
240
+ const METHOD_COLORS: Record<string, (s: string) => string> = {
241
+ GET: chalk.green,
242
+ POST: chalk.blue,
243
+ PUT: chalk.yellow,
244
+ PATCH: chalk.magenta,
245
+ DELETE: chalk.red,
246
+ };
247
+
248
+ function getMethodColor(method: string): (s: string) => string {
249
+ return METHOD_COLORS[method] ?? chalk.white;
250
+ }
251
+
252
+ function getStatusColor(status: number | keyof StatusMap): (s: string) => string {
253
+ const numericStatus = typeof status === "number" ? status : StatusMap[status];
254
+ if (numericStatus < 300) return chalk.green;
255
+ if (numericStatus < 400) return chalk.cyan;
256
+ if (numericStatus < 500) return chalk.yellow;
257
+ return chalk.red;
258
+ }
259
+
260
+ function createAppLogger(serviceName: string) {
261
+ const time = () => chalk.dim.yellow.bold.underline(formatTime());
262
+ const service = (c: (s: string) => string) => c(\`[\${serviceName}]\`);
263
+
264
+ return {
265
+ debug: (message: unknown, meta?: object) => {
266
+ if (!shouldLog("debug")) return;
267
+ console.log(
268
+ \`[\${chalk.cyan("DBG")}] \${time()} \${service(chalk.cyan)} \${messageParser(message)}\${formatMeta(meta)}\`,
269
+ );
270
+ },
271
+ info: (message: unknown, meta?: object) => {
272
+ if (!shouldLog("info")) return;
273
+ console.log(
274
+ \`[\${chalk.green("INFO")}] \${time()} \${service(chalk.green)} \${messageParser(message)}\${formatMeta(meta)}\`,
275
+ );
276
+ },
277
+ warn: (message: unknown, meta?: object) => {
278
+ if (!shouldLog("warn")) return;
279
+ console.log(
280
+ \`[\${chalk.yellow("WARN")}] \${time()} \${service(chalk.yellow)} \${messageParser(message)}\${formatMeta(meta)}\`,
281
+ );
282
+ },
283
+ error: (message: unknown, meta?: object) => {
284
+ if (!shouldLog("error")) return;
285
+ console.log(
286
+ \`[\${chalk.red("ERR")}] \${time()} \${service(chalk.red)} \${messageParser(message)}\${formatMeta(meta)}\`,
287
+ );
288
+ },
289
+ request: (
290
+ method: string,
291
+ url: string,
292
+ status?: number | keyof StatusMap,
293
+ ip: string = "",
294
+ duration?: string,
295
+ meta?: object,
296
+ ) => {
297
+ if (!shouldLog("info")) return;
298
+ const methodColor = getMethodColor(method);
299
+ const statusStr = status
300
+ ? \` \${getStatusColor(status)(String(status))}\`
301
+ : "";
302
+ const durationStr = duration ? \` \${chalk.cyan(duration)}\` : "";
303
+ console.log(
304
+ \`[\${chalk.magenta("REQ")}] \${time()} \${service(chalk.magenta)} \${methodColor(method)} \${url}\${statusStr}\${durationStr} \${ip} \${formatMeta(meta)}\`,
305
+ );
306
+ },
307
+ };
308
+ }
309
+
310
+ export const Logger = {
311
+ app: createAppLogger("App"),
312
+ auth: createAppLogger("Auth"),
313
+ travel: createAppLogger("Travel"),
314
+ db: createAppLogger("Database"),
315
+ upload: createAppLogger("Upload"),
316
+ of: createAppLogger,
317
+ };
318
+ `;
319
+ }
320
+
321
+ /**
322
+ * Config better-auth (`src/lib/auth.ts`). Avec Prisma, l'adapter est branché ;
323
+ * sinon il reste en commentaire (à activer après `add prisma`).
324
+ */
325
+ export function authLibTemplate(
326
+ withPrisma: boolean,
327
+ prismaProvider = "postgresql",
328
+ ): string {
329
+ const header = withPrisma
330
+ ? `import { betterAuth } from "better-auth";
331
+ import { openAPI } from "better-auth/plugins";
332
+ import { prismaAdapter } from "better-auth/adapters/prisma";
333
+ import prisma from "../db";`
334
+ : `import { betterAuth } from "better-auth";
335
+ import { openAPI } from "better-auth/plugins";
336
+ // import { prismaAdapter } from "better-auth/adapters/prisma";
337
+ // import prisma from "../db";`;
338
+
339
+ const database = withPrisma
340
+ ? ` database: prismaAdapter(prisma, { provider: "${prismaProvider}" }),`
341
+ : ` // database: prismaAdapter(prisma, { provider: "${prismaProvider}" }),`;
342
+
343
+ return `${header}
344
+
345
+ /**
346
+ * Instance better-auth. Handler monté via \`.mount(auth.handler)\` → routes sur
347
+ * /api/auth. Le plugin openAPI() expose le schéma consommé par @elysiajs/openapi.
348
+ */
349
+ export const auth = betterAuth({
350
+ ${database}
351
+ emailAndPassword: { enabled: true },
352
+ plugins: [openAPI()],
353
+ });
354
+
355
+ // Extraction du schéma OpenAPI de better-auth pour @elysiajs/openapi.
356
+ let _schema: ReturnType<typeof auth.api.generateOpenAPISchema>;
357
+ const getSchema = async () => (_schema ??= auth.api.generateOpenAPISchema());
358
+
359
+ export const OpenAPI = {
360
+ getPaths: (prefix = "/api/auth") =>
361
+ getSchema().then(({ paths }) => {
362
+ const reference: typeof paths = Object.create(null);
363
+ for (const path of Object.keys(paths)) {
364
+ const key = prefix + path;
365
+ reference[key] = paths[path];
366
+ for (const method of Object.keys(paths[path])) {
367
+ const operation = (reference[key] as any)[method];
368
+ operation.tags = ["Better Auth"];
369
+ }
370
+ }
371
+ return reference;
372
+ }) as Promise<any>,
373
+ components: getSchema().then(({ components }) => components) as Promise<any>,
374
+ } as const;
375
+ `;
376
+ }
377
+
378
+ /**
379
+ * Client Prisma singleton (`src/db/index.ts`).
380
+ * Prisma 7 requiert un driver adapter, dépendant du provider choisi.
381
+ */
382
+ export function prismaClientTemplate(adapter: {
383
+ adapterImport: string;
384
+ adapterPkg: string;
385
+ adapterExpr: string;
386
+ }): string {
387
+ return `import { ${adapter.adapterImport} } from "${adapter.adapterPkg}";
388
+ // Client généré vers un output custom (Prisma 7) — import depuis ce dossier,
389
+ // pas depuis "@prisma/client".
390
+ import { PrismaClient } from "../generated/prisma/client";
391
+
392
+ /**
393
+ * Singleton PrismaClient — évite d'ouvrir plusieurs pools de connexions
394
+ * (notamment en dev avec le hot-reload). Prisma 7 : la connexion passe par
395
+ * un adapter, l'URL est lue depuis DATABASE_URL (.env.local).
396
+ */
397
+ const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
398
+
399
+ const adapter = ${adapter.adapterExpr};
400
+
401
+ export const prisma = globalForPrisma.prisma ?? new PrismaClient({ adapter });
402
+
403
+ if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
404
+
405
+ export default prisma;
406
+ `;
407
+ }
408
+
409
+ /**
410
+ * Schéma Prisma (`prisma/schema.prisma`). Prisma 7 : plus d'\`url\` dans le
411
+ * datasource (déplacée dans prisma.config.ts). Génère aussi les modèles de
412
+ * validation Elysia via prismabox.
413
+ */
414
+ export function prismaSchemaTemplate(prismaProvider = "postgresql"): string {
415
+ return `generator client {
416
+ provider = "prisma-client"
417
+ output = "../src/generated/prisma"
418
+ }
419
+
420
+ generator prismabox {
421
+ provider = "prismabox"
422
+ typeboxImportDependencyName = "elysia"
423
+ typeboxImportVariableName = "t"
424
+ inputModel = true
425
+ output = "../src/generated/prismabox"
426
+ }
427
+
428
+ datasource db {
429
+ provider = "${prismaProvider}"
430
+ }
431
+ `;
432
+ }
433
+
434
+ /**
435
+ * Config Prisma (`prisma.config.ts`). Prisma 7 : l'URL de connexion (utilisée
436
+ * par Migrate) vit ici, chargée depuis .env.local.
437
+ */
438
+ export function prismaConfigTemplate(): string {
439
+ return `import { config } from "dotenv";
440
+ import { defineConfig, env } from "prisma/config";
441
+
442
+ // Prisma 7 charge .env par défaut ; on pointe explicitement sur .env.local.
443
+ config({ path: ".env.local" });
444
+
445
+ export default defineConfig({
446
+ schema: "./prisma/schema.prisma",
447
+ datasource: {
448
+ url: env("DATABASE_URL"),
449
+ },
450
+ });
451
+ `;
452
+ }
@@ -0,0 +1,90 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ export interface DbCredentials {
5
+ host: string;
6
+ port: string;
7
+ user: string;
8
+ password: string;
9
+ database: string;
10
+ }
11
+
12
+ /** Description d'un provider de base pour Prisma 7 (driver adapter requis). */
13
+ export interface DbProvider {
14
+ key: string;
15
+ label: string;
16
+ /** Valeur du datasource `provider` dans schema.prisma. */
17
+ prismaProvider: string;
18
+ /** Package de l'adapter Prisma. */
19
+ adapterPkg: string;
20
+ /** Nom de la classe adapter à importer. */
21
+ adapterImport: string;
22
+ /** Expression d'instanciation de l'adapter. */
23
+ adapterExpr: string;
24
+ /** Driver(s) natif(s) à installer. */
25
+ driverPkgs: string[];
26
+ /** Base fichier (SQLite) : pas de host/port/credentials. */
27
+ isFile: boolean;
28
+ /** Port par défaut (bases serveur). */
29
+ defaultPort?: string;
30
+ /** URL par défaut (SQLite : chemin du fichier). */
31
+ defaultUrl: string;
32
+ /** Construit l'URL de connexion à partir des composants (bases serveur). */
33
+ buildUrl?: (c: DbCredentials) => string;
34
+ }
35
+
36
+ export const DB_PROVIDERS: Record<string, DbProvider> = {
37
+ postgresql: {
38
+ key: "postgresql",
39
+ label: "PostgreSQL",
40
+ prismaProvider: "postgresql",
41
+ adapterPkg: "@prisma/adapter-pg",
42
+ adapterImport: "PrismaPg",
43
+ adapterExpr: "new PrismaPg({ connectionString: process.env.DATABASE_URL! })",
44
+ driverPkgs: ["pg"],
45
+ isFile: false,
46
+ defaultPort: "5432",
47
+ defaultUrl: "postgresql://user:password@localhost:5432/mydb?schema=public",
48
+ buildUrl: (c) =>
49
+ `postgresql://${c.user}:${c.password}@${c.host}:${c.port}/${c.database}?schema=public`,
50
+ },
51
+ mysql: {
52
+ key: "mysql",
53
+ label: "MySQL / MariaDB",
54
+ prismaProvider: "mysql",
55
+ adapterPkg: "@prisma/adapter-mariadb",
56
+ adapterImport: "PrismaMariaDb",
57
+ // Le driver mariadb accepte une URI de connexion.
58
+ adapterExpr: "new PrismaMariaDb(process.env.DATABASE_URL!)",
59
+ driverPkgs: ["mariadb"],
60
+ isFile: false,
61
+ defaultPort: "3306",
62
+ defaultUrl: "mysql://user:password@localhost:3306/mydb",
63
+ buildUrl: (c) =>
64
+ `mysql://${c.user}:${c.password}@${c.host}:${c.port}/${c.database}`,
65
+ },
66
+ sqlite: {
67
+ key: "sqlite",
68
+ label: "SQLite",
69
+ prismaProvider: "sqlite",
70
+ adapterPkg: "@prisma/adapter-better-sqlite3",
71
+ adapterImport: "PrismaBetterSqlite3",
72
+ adapterExpr:
73
+ 'new PrismaBetterSqlite3({ url: process.env.DATABASE_URL ?? "file:./dev.db" })',
74
+ driverPkgs: ["better-sqlite3"],
75
+ isFile: true,
76
+ defaultUrl: "file:./dev.db",
77
+ },
78
+ };
79
+
80
+ export const DB_PROVIDER_KEYS = Object.keys(DB_PROVIDERS);
81
+
82
+ /** Lit le provider déclaré dans prisma/schema.prisma, si présent. */
83
+ export function detectPrismaProvider(): string | null {
84
+ const schema = join(process.cwd(), "prisma", "schema.prisma");
85
+ if (!existsSync(schema)) return null;
86
+ const match = readFileSync(schema, "utf8").match(
87
+ /datasource\s+\w+\s*\{[^}]*provider\s*=\s*"([^"]+)"/,
88
+ );
89
+ return match?.[1] ?? null;
90
+ }
@@ -0,0 +1,30 @@
1
+ import { existsSync, readFileSync, appendFileSync } from "node:fs";
2
+ import { log } from "./logger.js";
3
+ import { ensureGitignore } from "./gitignore.js";
4
+
5
+ /**
6
+ * Garantit la présence de variables dans un fichier d'env (`.env.local` par
7
+ * défaut) sans écraser les valeurs existantes. Crée le fichier au besoin et
8
+ * s'assure qu'il est git-ignoré (les secrets ne doivent pas être committés).
9
+ */
10
+ export function ensureEnvVars(
11
+ vars: Record<string, string>,
12
+ file = ".env.local",
13
+ ): void {
14
+ // Toujours ignorer le fichier d'env, même si aucune variable n'est ajoutée.
15
+ ensureGitignore([file]);
16
+
17
+ const content = existsSync(file) ? readFileSync(file, "utf8") : "";
18
+ const missing: string[] = [];
19
+
20
+ for (const [key, value] of Object.entries(vars)) {
21
+ if (!new RegExp(`^${key}=`, "m").test(content)) {
22
+ missing.push(`${key}=${value}`);
23
+ }
24
+ }
25
+ if (!missing.length) return;
26
+
27
+ const prefix = content && !content.endsWith("\n") ? "\n" : "";
28
+ appendFileSync(file, prefix + missing.join("\n") + "\n");
29
+ log.updated(`${file} (${missing.length} variable(s))`);
30
+ }
@@ -0,0 +1,20 @@
1
+ import { existsSync, readFileSync, appendFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { log } from "./logger.js";
4
+
5
+ /**
6
+ * Garantit que des entrées figurent dans le .gitignore du projet courant.
7
+ * Évite notamment de committer les secrets (.env.local).
8
+ */
9
+ export function ensureGitignore(entries: string[]): void {
10
+ const path = join(process.cwd(), ".gitignore");
11
+ const content = existsSync(path) ? readFileSync(path, "utf8") : "";
12
+ const present = new Set(content.split(/\r?\n/).map((l) => l.trim()));
13
+
14
+ const missing = entries.filter((e) => !present.has(e));
15
+ if (!missing.length) return;
16
+
17
+ const prefix = content && !content.endsWith("\n") ? "\n" : "";
18
+ appendFileSync(path, prefix + missing.join("\n") + "\n");
19
+ log.updated(`.gitignore (${missing.join(", ")})`);
20
+ }