@aponiajs/cli 0.1.0 → 0.2.1

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/dist/index.mjs CHANGED
@@ -1,32 +1,189 @@
1
+ import { camelCase, kebabCase, pascalCase } from "change-case";
2
+ import parseCliArguments from "yargs-parser";
1
3
  import { mkdir, readdir } from "node:fs/promises";
2
- import { basename, dirname, join, relative } from "node:path";
4
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
3
5
  import { fileURLToPath } from "node:url";
6
+ import findFiles from "fast-glob";
7
+ import { singularize } from "inflection";
8
+ import { IndentationText, Node, Project, QuoteKind, SyntaxKind } from "ts-morph";
4
9
  //#region package.json
5
- var version = "0.1.0";
10
+ var version = "0.2.1";
6
11
  //#endregion
7
12
  //#region src/arguments.ts
13
+ const generateSchematics = [
14
+ "app",
15
+ "library",
16
+ "class",
17
+ "controller",
18
+ "decorator",
19
+ "filter",
20
+ "gateway",
21
+ "guard",
22
+ "interface",
23
+ "interceptor",
24
+ "middleware",
25
+ "module",
26
+ "pipe",
27
+ "provider",
28
+ "resolver",
29
+ "resource",
30
+ "service"
31
+ ];
32
+ const schematicAliases = {
33
+ app: "app",
34
+ application: "app",
35
+ lib: "library",
36
+ library: "library",
37
+ cl: "class",
38
+ class: "class",
39
+ co: "controller",
40
+ controller: "controller",
41
+ route: "controller",
42
+ router: "controller",
43
+ routes: "controller",
44
+ d: "decorator",
45
+ decorator: "decorator",
46
+ f: "filter",
47
+ filter: "filter",
48
+ ga: "gateway",
49
+ gateway: "gateway",
50
+ gu: "guard",
51
+ guard: "guard",
52
+ itf: "interface",
53
+ interface: "interface",
54
+ itc: "interceptor",
55
+ interceptor: "interceptor",
56
+ mi: "middleware",
57
+ middleware: "middleware",
58
+ mo: "module",
59
+ module: "module",
60
+ pi: "pipe",
61
+ pipe: "pipe",
62
+ pr: "provider",
63
+ provider: "provider",
64
+ r: "resolver",
65
+ resolver: "resolver",
66
+ res: "resource",
67
+ resource: "resource",
68
+ s: "service",
69
+ service: "service"
70
+ };
71
+ const resourceTransports = /* @__PURE__ */ new Set([
72
+ "rest",
73
+ "graphql-code-first",
74
+ "graphql-schema-first",
75
+ "microservice",
76
+ "ws"
77
+ ]);
8
78
  function parseArguments(arguments_) {
9
79
  const [command = "help", ...rest] = arguments_;
10
80
  if (command === "help" || command === "--help" || command === "-h") return { command: "help" };
11
81
  if (command === "version" || command === "--version" || command === "-v") return { command: "version" };
12
- if (command !== "new" && command !== "n") throw new Error(`Unknown command "${command}".`);
13
- const name = rest.find((argument) => !argument.startsWith("-"));
82
+ if (command === "new" || command === "n") return parseNewCommand(rest);
83
+ if (command === "generate" || command === "g") return parseGenerateCommand(rest);
84
+ throw new Error(`Unknown command "${command}".`);
85
+ }
86
+ function parseNewCommand(arguments_) {
87
+ const parsed = parseOptions(arguments_);
88
+ const [name, ...extraPositionals] = parsed._;
14
89
  if (!name) throw new Error("Project name is required.");
15
- const supportedOptions = /* @__PURE__ */ new Set([
16
- "--dry-run",
17
- "-d",
18
- "--skip-install",
19
- "-s"
20
- ]);
21
- const unknownOption = rest.find((argument) => argument.startsWith("-") && !supportedOptions.has(argument));
22
- if (unknownOption) throw new Error(`Unknown option "${unknownOption}".`);
90
+ if (extraPositionals.length > 0) throw new Error(`Unexpected argument "${extraPositionals[0]}".`);
91
+ assertKnownOptions(parsed, ["dry-run", "skip-install"]);
23
92
  return {
24
93
  command: "new",
25
94
  name,
26
- dryRun: rest.includes("--dry-run") || rest.includes("-d"),
27
- skipInstall: rest.includes("--skip-install") || rest.includes("-s")
95
+ dryRun: readBooleanOption(parsed, "dry-run", false),
96
+ skipInstall: readBooleanOption(parsed, "skip-install", false)
28
97
  };
29
98
  }
99
+ function parseGenerateCommand(arguments_) {
100
+ const parsed = parseOptions(arguments_);
101
+ const [schematicName, name, ...extraPositionals] = parsed._;
102
+ if (!schematicName) throw new Error("Schematic name is required.");
103
+ const schematic = schematicAliases[schematicName];
104
+ if (!schematic) throw new Error(`Unknown schematic "${schematicName}". Available schematics: ${generateSchematics.join(", ")}.`);
105
+ if (!name) throw new Error(`${pascalCase(schematic)} name is required.`);
106
+ if (extraPositionals.length > 0) throw new Error(`Unexpected argument "${extraPositionals[0]}".`);
107
+ assertKnownOptions(parsed, [
108
+ "crud",
109
+ "dry-run",
110
+ "flat",
111
+ "module",
112
+ "path",
113
+ "project",
114
+ "skip-import",
115
+ "spec",
116
+ "type"
117
+ ]);
118
+ const type = readStringOption(parsed, "type") ?? "rest";
119
+ if (!resourceTransports.has(type)) throw new Error(`Unknown resource transport "${type}".`);
120
+ return {
121
+ command: "generate",
122
+ schematic,
123
+ name,
124
+ dryRun: readBooleanOption(parsed, "dry-run", false),
125
+ flat: readOptionalBooleanOption(parsed, "flat"),
126
+ spec: readOptionalBooleanOption(parsed, "spec"),
127
+ skipImport: readBooleanOption(parsed, "skip-import", false),
128
+ path: readStringOption(parsed, "path"),
129
+ module: readStringOption(parsed, "module"),
130
+ project: readStringOption(parsed, "project"),
131
+ crud: readBooleanOption(parsed, "crud", true),
132
+ type
133
+ };
134
+ }
135
+ function parseOptions(arguments_) {
136
+ return parseCliArguments([...arguments_], {
137
+ alias: {
138
+ "dry-run": ["d"],
139
+ project: ["p"],
140
+ "skip-install": ["s"]
141
+ },
142
+ boolean: [
143
+ "crud",
144
+ "dry-run",
145
+ "flat",
146
+ "skip-import",
147
+ "skip-install",
148
+ "spec"
149
+ ],
150
+ string: [
151
+ "module",
152
+ "path",
153
+ "project",
154
+ "type"
155
+ ],
156
+ configuration: {
157
+ "camel-case-expansion": false,
158
+ "dot-notation": false,
159
+ "duplicate-arguments-array": false,
160
+ "parse-numbers": false,
161
+ "parse-positional-numbers": false,
162
+ "short-option-groups": false,
163
+ "strip-aliased": true
164
+ }
165
+ });
166
+ }
167
+ function assertKnownOptions(options, supportedOptions) {
168
+ const supported = new Set(supportedOptions);
169
+ const unknown = Object.keys(options).find((option) => option !== "_" && !supported.has(option));
170
+ if (unknown) throw new Error(`Unknown option "--${unknown}".`);
171
+ }
172
+ function readBooleanOption(options, name, fallback) {
173
+ return readOptionalBooleanOption(options, name) ?? fallback;
174
+ }
175
+ function readOptionalBooleanOption(options, name) {
176
+ const value = options[name];
177
+ if (value === void 0) return;
178
+ if (typeof value !== "boolean") throw new Error(`Option "--${name}" does not accept a value.`);
179
+ return value;
180
+ }
181
+ function readStringOption(options, name) {
182
+ const value = options[name];
183
+ if (value === void 0) return;
184
+ if (typeof value !== "string") throw new Error(`Option "--${name}" requires a value.`);
185
+ return value;
186
+ }
30
187
  //#endregion
31
188
  //#region src/project-generator.ts
32
189
  const projectNamePattern = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
@@ -82,6 +239,430 @@ async function listFiles(directory) {
82
239
  }))).flat();
83
240
  }
84
241
  //#endregion
242
+ //#region src/component-names.ts
243
+ function createComponentNames(input) {
244
+ const fileName = normalizeNameSegments(input).at(-1);
245
+ const singularFileName = singularize(fileName);
246
+ return {
247
+ fileName,
248
+ className: pascalCase(fileName),
249
+ propertyName: camelCase(fileName),
250
+ singularFileName,
251
+ singularClassName: pascalCase(singularFileName),
252
+ routePath: fileName
253
+ };
254
+ }
255
+ function normalizeNameSegments(input) {
256
+ if (isAbsolute(input) || input.includes("..")) throw new Error("Generated names must be relative and cannot contain parent traversal.");
257
+ const segments = input.replaceAll("\\", "/").split("/").filter(Boolean).map((segment) => kebabCase(segment, { locale: false }));
258
+ if (segments.length === 0 || segments.some((segment) => !/^[a-z][a-z0-9-]*$/.test(segment))) throw new Error("Generated names must contain letters and use kebab-case paths.");
259
+ return segments;
260
+ }
261
+ //#endregion
262
+ //#region src/module-registration.ts
263
+ function registerInModule(source, registrationKind, symbol, importPath) {
264
+ const sourceFile = new Project({
265
+ useInMemoryFileSystem: true,
266
+ manipulationSettings: {
267
+ indentationText: IndentationText.TwoSpaces,
268
+ quoteKind: QuoteKind.Double
269
+ }
270
+ }).createSourceFile("module.ts", source);
271
+ const moduleMetadata = findModuleMetadata(sourceFile);
272
+ if (!moduleMetadata) throw new Error("The declaring module does not contain a @Module() metadata object.");
273
+ if (isAlreadyRegistered(sourceFile, registrationKind, symbol, importPath)) return source;
274
+ addImport(sourceFile, symbol, importPath);
275
+ addRegistration(moduleMetadata, registrationKind, symbol);
276
+ return sourceFile.getFullText();
277
+ }
278
+ function findModuleMetadata(sourceFile) {
279
+ const metadata = sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression).find((callExpression) => callExpression.getExpression().getText() === "Module")?.getArguments()[0];
280
+ return Node.isObjectLiteralExpression(metadata) ? metadata : void 0;
281
+ }
282
+ function isAlreadyRegistered(sourceFile, registrationKind, symbol, importPath) {
283
+ if (!sourceFile.getImportDeclarations().some((declaration) => declaration.getModuleSpecifierValue() === importPath && declaration.getNamedImports().some((namedImport) => namedImport.getName() === symbol))) return false;
284
+ const registration = findModuleMetadata(sourceFile)?.getProperty(registrationKind);
285
+ if (!Node.isPropertyAssignment(registration)) return false;
286
+ const initializer = registration.getInitializer();
287
+ return Node.isArrayLiteralExpression(initializer) && initializer.getElements().some((element) => element.getText() === symbol);
288
+ }
289
+ function addImport(sourceFile, symbol, importPath) {
290
+ const existingImport = sourceFile.getImportDeclarations().find((declaration) => declaration.getModuleSpecifierValue() === importPath);
291
+ if (existingImport) {
292
+ if (!existingImport.getNamedImports().some((namedImport) => namedImport.getName() === symbol)) existingImport.addNamedImport(symbol);
293
+ return;
294
+ }
295
+ sourceFile.addImportDeclaration({
296
+ namedImports: [symbol],
297
+ moduleSpecifier: importPath
298
+ });
299
+ }
300
+ function addRegistration(metadata, registrationKind, symbol) {
301
+ const registration = metadata.getProperty(registrationKind);
302
+ if (!registration) {
303
+ metadata.addPropertyAssignment({
304
+ name: registrationKind,
305
+ initializer: `[${symbol}]`
306
+ });
307
+ return;
308
+ }
309
+ if (!Node.isPropertyAssignment(registration)) throw new Error(`Module metadata "${registrationKind}" must be a property assignment.`);
310
+ const initializer = registration.getInitializer();
311
+ if (!Node.isArrayLiteralExpression(initializer)) throw new Error(`Module metadata "${registrationKind}" must be an array.`);
312
+ if (!initializer.getElements().some((element) => element.getText() === symbol)) initializer.addElement(symbol);
313
+ }
314
+ //#endregion
315
+ //#region src/schematic-generator.ts
316
+ const definitions = {
317
+ class: {
318
+ defaultFlat: true,
319
+ spec: true,
320
+ suffix: ""
321
+ },
322
+ controller: {
323
+ defaultFlat: false,
324
+ spec: true,
325
+ suffix: "controller",
326
+ registration: "controllers"
327
+ },
328
+ decorator: {
329
+ defaultFlat: true,
330
+ spec: false,
331
+ suffix: "decorator"
332
+ },
333
+ filter: {
334
+ defaultFlat: true,
335
+ spec: true,
336
+ suffix: "filter"
337
+ },
338
+ gateway: {
339
+ defaultFlat: true,
340
+ spec: true,
341
+ suffix: "gateway",
342
+ registration: "providers"
343
+ },
344
+ guard: {
345
+ defaultFlat: true,
346
+ spec: true,
347
+ suffix: "guard"
348
+ },
349
+ interface: {
350
+ defaultFlat: true,
351
+ spec: false,
352
+ suffix: "interface"
353
+ },
354
+ interceptor: {
355
+ defaultFlat: true,
356
+ spec: true,
357
+ suffix: "interceptor"
358
+ },
359
+ middleware: {
360
+ defaultFlat: true,
361
+ spec: true,
362
+ suffix: "middleware"
363
+ },
364
+ module: {
365
+ defaultFlat: false,
366
+ spec: false,
367
+ suffix: "module",
368
+ registration: "imports"
369
+ },
370
+ pipe: {
371
+ defaultFlat: true,
372
+ spec: true,
373
+ suffix: "pipe"
374
+ },
375
+ provider: {
376
+ defaultFlat: true,
377
+ spec: true,
378
+ suffix: "",
379
+ registration: "providers"
380
+ },
381
+ resolver: {
382
+ defaultFlat: false,
383
+ spec: true,
384
+ suffix: "resolver",
385
+ registration: "providers"
386
+ },
387
+ service: {
388
+ defaultFlat: false,
389
+ spec: true,
390
+ suffix: "service",
391
+ registration: "providers"
392
+ }
393
+ };
394
+ async function generateSchematic(options) {
395
+ const projectRoot = resolve(options.cwd ?? process.cwd());
396
+ const names = createComponentNames(options.name);
397
+ if (options.schematic === "app") return generateApplication(projectRoot, names.fileName, options);
398
+ if (options.schematic === "library") return generateLibrary(projectRoot, names, options);
399
+ const configuration = await readConfiguration(projectRoot);
400
+ const project = resolveProject(configuration, options.project);
401
+ const sourceRoot = resolveInside(projectRoot, project.sourceRoot ?? configuration.sourceRoot ?? "src");
402
+ const basePath = options.path ? resolveInside(projectRoot, options.path) : sourceRoot;
403
+ const spec = options.spec ?? readSpecDefault(project.generateOptions?.spec, options.schematic) ?? readSpecDefault(configuration.generateOptions?.spec, options.schematic) ?? true;
404
+ const effectiveOptions = {
405
+ ...options,
406
+ flat: options.flat ?? project.generateOptions?.flat ?? configuration.generateOptions?.flat
407
+ };
408
+ const files = options.schematic === "resource" ? createResourceFiles(basePath, names, effectiveOptions, spec) : createComponentFiles(basePath, names, effectiveOptions, spec);
409
+ const registration = registrationFor(options.schematic);
410
+ if (registration && !options.skipImport) {
411
+ const primary = primaryFile(files, options.schematic);
412
+ const moduleFile = await findModuleFile({
413
+ projectRoot,
414
+ sourceRoot,
415
+ fromDirectory: dirname(primary.path),
416
+ requestedModule: options.module,
417
+ excludedFile: options.schematic === "module" ? primary.path : void 0
418
+ });
419
+ if (moduleFile) {
420
+ const original = await Bun.file(moduleFile).text();
421
+ const updated = registerInModule(original, registration, symbolFor(options.schematic, names), toImportPath(moduleFile, primary.path));
422
+ if (updated !== original) files.push({
423
+ path: moduleFile,
424
+ content: updated,
425
+ kind: "UPDATE"
426
+ });
427
+ }
428
+ }
429
+ return writePendingFiles(projectRoot, files, options.dryRun);
430
+ }
431
+ function createComponentFiles(basePath, names, options, specEnabled) {
432
+ const schematic = options.schematic;
433
+ const definition = definitions[schematic];
434
+ const flat = options.flat ?? definition.defaultFlat;
435
+ const directory = join(basePath, ...normalizeNameSegments(options.name).slice(0, -1), ...flat ? [] : [names.fileName]);
436
+ const stem = definition.suffix ? `${names.fileName}.${definition.suffix}` : names.fileName;
437
+ const files = [{
438
+ path: join(directory, `${stem}.ts`),
439
+ content: renderComponent(schematic, names),
440
+ kind: "CREATE"
441
+ }];
442
+ if (definition.spec && specEnabled) files.push({
443
+ path: join(directory, `${stem}.spec.ts`),
444
+ content: renderComponentSpec(schematic, names, stem),
445
+ kind: "CREATE"
446
+ });
447
+ return files;
448
+ }
449
+ function createResourceFiles(basePath, names, options, specEnabled) {
450
+ const flat = options.flat ?? false;
451
+ const directory = join(basePath, ...normalizeNameSegments(options.name).slice(0, -1), ...flat ? [] : [names.fileName]);
452
+ const transportStem = resourceTransportStem(options.type);
453
+ const dtoSuffix = options.type.startsWith("graphql") ? "input" : "dto";
454
+ const files = [createFile(directory, `${names.fileName}.module.ts`, renderResourceModule(names, transportStem)), createFile(directory, `${names.fileName}.service.ts`, renderResourceService(names, options.crud, dtoSuffix))];
455
+ if (options.crud) files.push(createFile(join(directory, "dto"), `create-${names.singularFileName}.${dtoSuffix}.ts`, renderCreateDto(names, dtoSuffix)), createFile(join(directory, "dto"), `update-${names.singularFileName}.${dtoSuffix}.ts`, renderUpdateDto(names, dtoSuffix)), createFile(join(directory, "entities"), `${names.singularFileName}.entity.ts`, renderEntity(names)));
456
+ if (options.type === "rest") files.push(createFile(directory, `${names.fileName}.controller.ts`, renderResourceController(names, options.crud)));
457
+ else files.push(createFile(directory, `${names.fileName}.${transportStem}.ts`, renderResourceTransport(names, options.type, options.crud)));
458
+ if (specEnabled) files.push(createFile(directory, `${names.fileName}.service.spec.ts`, options.crud ? renderResourceServiceSpec(names) : renderSimpleSpec(`./${names.fileName}.service.ts`, `${names.className}Service`)), createFile(directory, `${names.fileName}.${transportStem}.spec.ts`, renderResourceTransportSpec(names, transportStem)));
459
+ return files;
460
+ }
461
+ async function generateApplication(projectRoot, name, options) {
462
+ const appsDirectory = join(projectRoot, "apps");
463
+ if (!options.dryRun) await mkdir(appsDirectory, { recursive: true });
464
+ const result = await generateProject({
465
+ name,
466
+ cwd: appsDirectory,
467
+ dryRun: options.dryRun,
468
+ skipInstall: true
469
+ });
470
+ return {
471
+ dryRun: options.dryRun,
472
+ changes: result.files.map((file) => ({
473
+ kind: "CREATE",
474
+ path: join("apps", name, file)
475
+ }))
476
+ };
477
+ }
478
+ async function generateLibrary(projectRoot, names, options) {
479
+ const directory = join(projectRoot, "packages", names.fileName);
480
+ return writePendingFiles(projectRoot, [
481
+ createFile(directory, "package.json", `${JSON.stringify({
482
+ name: names.fileName,
483
+ version: "0.0.0",
484
+ private: true,
485
+ type: "module",
486
+ scripts: {
487
+ build: "bun build ./src/index.ts --outdir ./dist --target bun",
488
+ test: "bun test"
489
+ },
490
+ dependencies: { "@aponiajs/common": "latest" }
491
+ }, void 0, 2)}\n`),
492
+ createFile(join(directory, "src"), `${names.fileName}.module.ts`, `import { Module } from "@aponiajs/common";\n\n@Module({})\nexport class ${names.className}Module {}\n`),
493
+ createFile(join(directory, "src"), `${names.fileName}.service.ts`, `import { Injectable } from "@aponiajs/common";\n\n@Injectable()\nexport class ${names.className}Service {}\n`),
494
+ createFile(join(directory, "src"), `${names.fileName}.service.spec.ts`, renderSimpleSpec(`./${names.fileName}.service.ts`, `${names.className}Service`)),
495
+ createFile(join(directory, "src"), "index.ts", `export { ${names.className}Module } from "./${names.fileName}.module.ts";\nexport { ${names.className}Service } from "./${names.fileName}.service.ts";\n`)
496
+ ], options.dryRun);
497
+ }
498
+ async function writePendingFiles(projectRoot, files, dryRun) {
499
+ const creates = files.filter((file) => file.kind === "CREATE");
500
+ for (const file of creates) if (await Bun.file(file.path).exists()) throw new Error(`File "${relative(projectRoot, file.path)}" already exists.`);
501
+ if (!dryRun) for (const file of files) {
502
+ await mkdir(dirname(file.path), { recursive: true });
503
+ await Bun.write(file.path, file.content);
504
+ }
505
+ return {
506
+ dryRun,
507
+ changes: files.map((file) => ({
508
+ kind: file.kind,
509
+ path: relative(projectRoot, file.path)
510
+ }))
511
+ };
512
+ }
513
+ function renderComponent(schematic, names) {
514
+ const className = `${names.className}${classSuffix(schematic)}`;
515
+ switch (schematic) {
516
+ case "controller": return `import { Controller } from "@aponiajs/common";\n\n@Controller("${names.routePath}")\nexport class ${className} {}\n`;
517
+ case "module": return `import { Module } from "@aponiajs/common";\n\n@Module({})\nexport class ${className} {}\n`;
518
+ case "service":
519
+ case "provider": return `import { Injectable } from "@aponiajs/common";\n\n@Injectable()\nexport class ${className} {}\n`;
520
+ case "decorator": return `export function ${className}(): MethodDecorator {\n return () => undefined;\n}\n`;
521
+ case "interface": return `export interface ${className} {}\n`;
522
+ case "guard": return `export class ${className} {\n canActivate(): boolean {\n return true;\n }\n}\n`;
523
+ case "interceptor": return `export class ${className} {\n intercept<T>(next: () => T): T {\n return next();\n }\n}\n`;
524
+ case "middleware": return `export class ${className} {\n async use(request: Request, next: () => Response | Promise<Response>): Promise<Response> {\n void request;\n return next();\n }\n}\n`;
525
+ case "pipe": return `export class ${className} {\n transform<T>(value: T): T {\n return value;\n }\n}\n`;
526
+ case "filter": return `export class ${className} {\n catch(error: unknown): unknown {\n return error;\n }\n}\n`;
527
+ default: return `export class ${className} {}\n`;
528
+ }
529
+ }
530
+ function renderComponentSpec(schematic, names, stem) {
531
+ return renderSimpleSpec(`./${stem}.ts`, `${names.className}${classSuffix(schematic)}`);
532
+ }
533
+ function renderSimpleSpec(importPath, className) {
534
+ return `import { expect, test } from "bun:test";\nimport { ${className} } from "${importPath}";\n\ntest("${className} is defined", () => {\n expect(new ${className}()).toBeDefined();\n});\n`;
535
+ }
536
+ function renderResourceModule(names, transportStem) {
537
+ const transportClass = `${names.className}${pascalCase(transportStem)}`;
538
+ const metadata = transportStem === "controller" ? `controllers: [${transportClass}],\n providers: [${names.className}Service],` : `providers: [${transportClass}, ${names.className}Service],`;
539
+ return `import { Module } from "@aponiajs/common";\nimport { ${transportClass} } from "./${names.fileName}.${transportStem}.ts";\nimport { ${names.className}Service } from "./${names.fileName}.service.ts";\n\n@Module({\n ${metadata}\n})\nexport class ${names.className}Module {}\n`;
540
+ }
541
+ function renderResourceController(names, crud) {
542
+ if (!crud) return `import { Controller } from "@aponiajs/common";\nimport { ${names.className}Service } from "./${names.fileName}.service.ts";\n\n@Controller("${names.routePath}")\nexport class ${names.className}Controller {\n constructor(private readonly ${names.propertyName}Service: ${names.className}Service) {}\n}\n`;
543
+ return `import { Controller, Delete, Get, Patch, Post } from "@aponiajs/common";\nimport type { Create${names.singularClassName}Dto } from "./dto/create-${names.singularFileName}.dto.ts";\nimport type { Update${names.singularClassName}Dto } from "./dto/update-${names.singularFileName}.dto.ts";\nimport { ${names.className}Service } from "./${names.fileName}.service.ts";\n\n@Controller("${names.routePath}")\nexport class ${names.className}Controller {\n constructor(private readonly ${names.propertyName}Service: ${names.className}Service) {}\n\n @Post()\n create(input: Create${names.singularClassName}Dto) {\n return this.${names.propertyName}Service.create(input);\n }\n\n @Get()\n findAll() {\n return this.${names.propertyName}Service.findAll();\n }\n\n @Get(":id")\n findOne(id: string) {\n return this.${names.propertyName}Service.findOne(id);\n }\n\n @Patch(":id")\n update(id: string, input: Update${names.singularClassName}Dto) {\n return this.${names.propertyName}Service.update(id, input);\n }\n\n @Delete(":id")\n remove(id: string) {\n return this.${names.propertyName}Service.remove(id);\n }\n}\n`;
544
+ }
545
+ function renderResourceService(names, crud, dtoSuffix) {
546
+ if (!crud) return `import { Injectable } from "@aponiajs/common";\n\n@Injectable()\nexport class ${names.className}Service {}\n`;
547
+ const typeSuffix = pascalCase(dtoSuffix);
548
+ return `import { Injectable } from "@aponiajs/common";\nimport type { Create${names.singularClassName}${typeSuffix} } from "./dto/create-${names.singularFileName}.${dtoSuffix}.ts";\nimport type { Update${names.singularClassName}${typeSuffix} } from "./dto/update-${names.singularFileName}.${dtoSuffix}.ts";\nimport type { ${names.singularClassName} } from "./entities/${names.singularFileName}.entity.ts";\n\n@Injectable()\nexport class ${names.className}Service {\n private readonly items: ${names.singularClassName}[] = [];\n\n create(input: Create${names.singularClassName}${typeSuffix}): ${names.singularClassName} {\n const item = { id: crypto.randomUUID(), ...input };\n this.items.push(item);\n return item;\n }\n\n findAll(): readonly ${names.singularClassName}[] {\n return this.items;\n }\n\n findOne(id: string): ${names.singularClassName} | undefined {\n return this.items.find((item) => item.id === id);\n }\n\n update(id: string, input: Update${names.singularClassName}${typeSuffix}): ${names.singularClassName} | undefined {\n const item = this.findOne(id);\n if (!item) return undefined;\n Object.assign(item, input);\n return item;\n }\n\n remove(id: string): boolean {\n const index = this.items.findIndex((item) => item.id === id);\n if (index < 0) return false;\n this.items.splice(index, 1);\n return true;\n }\n}\n`;
549
+ }
550
+ function renderCreateDto(names, suffix) {
551
+ const typeSuffix = pascalCase(suffix);
552
+ return `export class Create${names.singularClassName}${typeSuffix} {\n name = "";\n}\n`;
553
+ }
554
+ function renderUpdateDto(names, suffix) {
555
+ const typeSuffix = pascalCase(suffix);
556
+ return `import type { Create${names.singularClassName}${typeSuffix} } from "./create-${names.singularFileName}.${suffix}.ts";\n\nexport type Update${names.singularClassName}${typeSuffix} = Partial<Create${names.singularClassName}${typeSuffix}>;\n`;
557
+ }
558
+ function renderEntity(names) {
559
+ return `export class ${names.singularClassName} {\n id = "";\n name = "";\n}\n`;
560
+ }
561
+ function renderResourceTransport(names, type, crud) {
562
+ const stem = resourceTransportStem(type);
563
+ const className = `${names.className}${pascalCase(stem)}`;
564
+ const method = crud ? `\n findAll() {\n return this.${names.propertyName}Service.findAll();\n }\n` : "";
565
+ return `import { ${names.className}Service } from "./${names.fileName}.service.ts";\n\n/** ${type} transport scaffold. Connect this class to the matching Aponia platform package. */\nexport class ${className} {\n constructor(private readonly ${names.propertyName}Service: ${names.className}Service) {}\n${method}}\n`;
566
+ }
567
+ function renderResourceServiceSpec(names) {
568
+ return `import { expect, test } from "bun:test";\nimport { ${names.className}Service } from "./${names.fileName}.service.ts";\n\ntest("${names.className}Service creates and returns resources", () => {\n const service = new ${names.className}Service();\n const created = service.create({ name: "sample" });\n expect(service.findOne(created.id)).toEqual(created);\n});\n`;
569
+ }
570
+ function renderResourceTransportSpec(names, stem) {
571
+ const className = `${names.className}${pascalCase(stem)}`;
572
+ return `import { expect, test } from "bun:test";\nimport { ${className} } from "./${names.fileName}.${stem}.ts";\nimport { ${names.className}Service } from "./${names.fileName}.service.ts";\n\ntest("${className} is defined", () => {\n expect(new ${className}(new ${names.className}Service())).toBeDefined();\n});\n`;
573
+ }
574
+ function classSuffix(schematic) {
575
+ if (schematic === "class" || schematic === "provider" || schematic === "interface") return "";
576
+ return pascalCase(schematic);
577
+ }
578
+ function symbolFor(schematic, names) {
579
+ if (schematic === "resource") return `${names.className}Module`;
580
+ return `${names.className}${classSuffix(schematic)}`;
581
+ }
582
+ function registrationFor(schematic) {
583
+ if (schematic === "resource") return "imports";
584
+ if (schematic === "app" || schematic === "library") return void 0;
585
+ return definitions[schematic].registration;
586
+ }
587
+ function primaryFile(files, schematic) {
588
+ const expectedSuffix = schematic === "resource" ? ".module.ts" : definitions[schematic].suffix ? `.${definitions[schematic].suffix}.ts` : ".ts";
589
+ return files.find((file) => file.path.endsWith(expectedSuffix) && !file.path.endsWith(".spec.ts"));
590
+ }
591
+ function resourceTransportStem(type) {
592
+ if (type === "ws") return "gateway";
593
+ if (type.startsWith("graphql")) return "resolver";
594
+ return "controller";
595
+ }
596
+ function createFile(directory, name, content) {
597
+ return {
598
+ path: join(directory, name),
599
+ content,
600
+ kind: "CREATE"
601
+ };
602
+ }
603
+ async function readConfiguration(projectRoot) {
604
+ const file = Bun.file(join(projectRoot, "aponia.json"));
605
+ if (!await file.exists()) throw new Error("Could not find \"aponia.json\". Run the command from an Aponia project root.");
606
+ return await file.json();
607
+ }
608
+ function resolveProject(configuration, requestedProject) {
609
+ if (!requestedProject) return {};
610
+ const project = configuration.projects?.[requestedProject];
611
+ if (!project) throw new Error(`Unknown project "${requestedProject}" in aponia.json.`);
612
+ return {
613
+ root: project.root,
614
+ sourceRoot: project.sourceRoot ?? join(project.root ?? "", "src"),
615
+ generateOptions: project.generateOptions
616
+ };
617
+ }
618
+ function readSpecDefault(value, schematic) {
619
+ if (typeof value === "boolean" || value === void 0) return value;
620
+ return value[schematic];
621
+ }
622
+ function resolveInside(root, path) {
623
+ const resolved = resolve(root, path);
624
+ if (resolved !== root && !resolved.startsWith(`${root}${sep}`)) throw new Error(`Path "${path}" escapes the project root.`);
625
+ return resolved;
626
+ }
627
+ async function findModuleFile(options) {
628
+ if (options.requestedModule) {
629
+ const candidates = await listModuleFiles(options.sourceRoot);
630
+ const normalized = options.requestedModule.replace(/\.module(?:\.ts)?$/, "");
631
+ const matches = candidates.filter((file) => {
632
+ const relativeFile = relative(options.sourceRoot, file).replaceAll("\\", "/");
633
+ return relativeFile === `${normalized}.module.ts` || relativeFile.endsWith(`/${normalized}.module.ts`);
634
+ });
635
+ if (matches.length !== 1) throw new Error(matches.length === 0 ? `Could not find module "${options.requestedModule}".` : `Module "${options.requestedModule}" is ambiguous.`);
636
+ return matches[0];
637
+ }
638
+ let directory = options.fromDirectory;
639
+ while (directory === options.sourceRoot || directory.startsWith(`${options.sourceRoot}${sep}`)) {
640
+ const modules = (await safeReadDirectory(directory)).filter((entry) => entry.endsWith(".module.ts")).map((entry) => join(directory, entry)).filter((file) => file !== options.excludedFile).toSorted();
641
+ if (modules.length > 0) return modules[0];
642
+ if (directory === options.sourceRoot) break;
643
+ directory = dirname(directory);
644
+ }
645
+ }
646
+ async function listModuleFiles(directory) {
647
+ return findFiles("**/*.module.ts", {
648
+ absolute: true,
649
+ cwd: directory,
650
+ onlyFiles: true
651
+ });
652
+ }
653
+ async function safeReadDirectory(directory) {
654
+ try {
655
+ return await readdir(directory);
656
+ } catch {
657
+ return [];
658
+ }
659
+ }
660
+ function toImportPath(moduleFile, generatedFile) {
661
+ let path = relative(dirname(moduleFile), generatedFile).replaceAll("\\", "/");
662
+ if (!path.startsWith(".")) path = `./${path}`;
663
+ return path;
664
+ }
665
+ //#endregion
85
666
  //#region src/index.ts
86
667
  async function runCli(arguments_) {
87
668
  try {
@@ -94,6 +675,11 @@ async function runCli(arguments_) {
94
675
  console.log(version);
95
676
  return 0;
96
677
  }
678
+ if (command.command === "generate") {
679
+ const result = await generateSchematic(command);
680
+ for (const change of result.changes) console.log(`${change.kind} ${change.path}`);
681
+ return 0;
682
+ }
97
683
  const result = await generateProject(command);
98
684
  if (result.dryRun) {
99
685
  console.log(`CREATE ${result.projectDirectory}`);
@@ -114,12 +700,34 @@ const helpText = `Aponia CLI
114
700
  Usage:
115
701
  aponia new <name> [options]
116
702
  aponia n <name> [options]
703
+ aponia generate <schematic> <name> [options]
704
+ aponia g <schematic> <name> [options]
117
705
 
118
706
  Options:
119
707
  -d, --dry-run Report files without writing them
120
708
  -s, --skip-install Generate without running bun install
709
+ --flat Generate without a schematic directory
710
+ --no-flat Generate inside a schematic directory
711
+ --spec Generate spec files
712
+ --no-spec Skip spec files
713
+ --skip-import Skip declaring module registration
714
+ --module <name> Select the declaring module
715
+ --path <path> Override the configured source root
716
+ -p, --project Select a configured project
717
+ --type <type> Select a resource transport
718
+ --crud Generate CRUD entry points (default)
719
+ --no-crud Generate a resource without CRUD entry points
121
720
  -h, --help Show command help
122
721
  -v, --version Show CLI version
722
+
723
+ Schematics:
724
+ app, library (lib), class (cl), controller (co), decorator (d),
725
+ filter (f), gateway (ga), guard (gu), interface (itf),
726
+ interceptor (itc), middleware (mi), module (mo), pipe (pi),
727
+ provider (pr), resolver (r), resource (res), service (s)
728
+
729
+ Controller aliases:
730
+ router, routers, route
123
731
  `;
124
732
  //#endregion
125
- export { generateProject, parseArguments, runCli };
733
+ export { generateProject, generateSchematic, generateSchematics, parseArguments, runCli };