@aponiajs/cli 0.1.0 → 0.2.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aponiajs/cli",
3
- "version": "0.1.0",
3
+ "version": "0.2.2",
4
4
  "description": "Bun-native project generator and command-line interface for Aponia.",
5
5
  "homepage": "https://github.com/aponiajs/aponiajs#readme",
6
6
  "bugs": {
@@ -35,5 +35,15 @@
35
35
  "test": "bun test",
36
36
  "check": "bunx --bun vp check",
37
37
  "prepublishOnly": "bun run build"
38
+ },
39
+ "dependencies": {
40
+ "change-case": "^5.4.4",
41
+ "fast-glob": "^3.3.3",
42
+ "inflection": "^3.0.2",
43
+ "ts-morph": "^28.0.0",
44
+ "yargs-parser": "^22.0.0"
45
+ },
46
+ "devDependencies": {
47
+ "@types/yargs-parser": "^21.0.3"
38
48
  }
39
49
  }
package/src/arguments.ts CHANGED
@@ -1,3 +1,6 @@
1
+ import { pascalCase } from "change-case";
2
+ import parseCliArguments from "yargs-parser";
3
+
1
4
  export interface NewCommandOptions {
2
5
  readonly command: "new";
3
6
  readonly name: string;
@@ -5,11 +8,104 @@ export interface NewCommandOptions {
5
8
  readonly skipInstall: boolean;
6
9
  }
7
10
 
11
+ export const generateSchematics = [
12
+ "app",
13
+ "library",
14
+ "class",
15
+ "controller",
16
+ "decorator",
17
+ "filter",
18
+ "gateway",
19
+ "guard",
20
+ "interface",
21
+ "interceptor",
22
+ "middleware",
23
+ "module",
24
+ "pipe",
25
+ "provider",
26
+ "resolver",
27
+ "resource",
28
+ "service",
29
+ ] as const;
30
+
31
+ export type GenerateSchematic = (typeof generateSchematics)[number];
32
+
33
+ export type ResourceTransport =
34
+ | "rest"
35
+ | "graphql-code-first"
36
+ | "graphql-schema-first"
37
+ | "microservice"
38
+ | "ws";
39
+
40
+ export interface GenerateCommandOptions {
41
+ readonly command: "generate";
42
+ readonly schematic: GenerateSchematic;
43
+ readonly name: string;
44
+ readonly dryRun: boolean;
45
+ readonly flat?: boolean;
46
+ readonly spec?: boolean;
47
+ readonly skipImport: boolean;
48
+ readonly path?: string;
49
+ readonly module?: string;
50
+ readonly project?: string;
51
+ readonly crud: boolean;
52
+ readonly type: ResourceTransport;
53
+ }
54
+
8
55
  export type CliCommand =
9
56
  | NewCommandOptions
57
+ | GenerateCommandOptions
10
58
  | { readonly command: "help" }
11
59
  | { readonly command: "version" };
12
60
 
61
+ const schematicAliases: Readonly<Record<string, GenerateSchematic>> = {
62
+ app: "app",
63
+ application: "app",
64
+ lib: "library",
65
+ library: "library",
66
+ cl: "class",
67
+ class: "class",
68
+ co: "controller",
69
+ controller: "controller",
70
+ route: "controller",
71
+ router: "controller",
72
+ routes: "controller",
73
+ d: "decorator",
74
+ decorator: "decorator",
75
+ f: "filter",
76
+ filter: "filter",
77
+ ga: "gateway",
78
+ gateway: "gateway",
79
+ gu: "guard",
80
+ guard: "guard",
81
+ itf: "interface",
82
+ interface: "interface",
83
+ itc: "interceptor",
84
+ interceptor: "interceptor",
85
+ mi: "middleware",
86
+ middleware: "middleware",
87
+ mo: "module",
88
+ module: "module",
89
+ pi: "pipe",
90
+ pipe: "pipe",
91
+ pr: "provider",
92
+ provider: "provider",
93
+ r: "resolver",
94
+ resolver: "resolver",
95
+ res: "resource",
96
+ resource: "resource",
97
+ s: "service",
98
+ service: "service",
99
+ };
100
+
101
+ const resourceTransports = new Set<ResourceTransport>([
102
+ "rest",
103
+ "graphql-code-first",
104
+ "graphql-schema-first",
105
+ "microservice",
106
+ "ws",
107
+ ]);
108
+
13
109
  export function parseArguments(arguments_: readonly string[]): CliCommand {
14
110
  const [command = "help", ...rest] = arguments_;
15
111
 
@@ -21,27 +117,145 @@ export function parseArguments(arguments_: readonly string[]): CliCommand {
21
117
  return { command: "version" };
22
118
  }
23
119
 
24
- if (command !== "new" && command !== "n") {
25
- throw new Error(`Unknown command "${command}".`);
120
+ if (command === "new" || command === "n") {
121
+ return parseNewCommand(rest);
122
+ }
123
+
124
+ if (command === "generate" || command === "g") {
125
+ return parseGenerateCommand(rest);
26
126
  }
27
127
 
28
- const name = rest.find((argument) => !argument.startsWith("-"));
128
+ throw new Error(`Unknown command "${command}".`);
129
+ }
130
+
131
+ function parseNewCommand(arguments_: readonly string[]): NewCommandOptions {
132
+ const parsed = parseOptions(arguments_);
133
+ const [name, ...extraPositionals] = parsed._;
29
134
  if (!name) {
30
135
  throw new Error("Project name is required.");
31
136
  }
32
-
33
- const supportedOptions = new Set(["--dry-run", "-d", "--skip-install", "-s"]);
34
- const unknownOption = rest.find(
35
- (argument) => argument.startsWith("-") && !supportedOptions.has(argument),
36
- );
37
- if (unknownOption) {
38
- throw new Error(`Unknown option "${unknownOption}".`);
137
+ if (extraPositionals.length > 0) {
138
+ throw new Error(`Unexpected argument "${extraPositionals[0]}".`);
39
139
  }
40
140
 
141
+ assertKnownOptions(parsed, ["dry-run", "skip-install"]);
142
+
41
143
  return {
42
144
  command: "new",
43
145
  name,
44
- dryRun: rest.includes("--dry-run") || rest.includes("-d"),
45
- skipInstall: rest.includes("--skip-install") || rest.includes("-s"),
146
+ dryRun: readBooleanOption(parsed, "dry-run", false),
147
+ skipInstall: readBooleanOption(parsed, "skip-install", false),
148
+ };
149
+ }
150
+
151
+ function parseGenerateCommand(arguments_: readonly string[]): GenerateCommandOptions {
152
+ const parsed = parseOptions(arguments_);
153
+ const [schematicName, name, ...extraPositionals] = parsed._;
154
+ if (!schematicName) {
155
+ throw new Error("Schematic name is required.");
156
+ }
157
+
158
+ const schematic = schematicAliases[schematicName];
159
+ if (!schematic) {
160
+ throw new Error(
161
+ `Unknown schematic "${schematicName}". Available schematics: ${generateSchematics.join(", ")}.`,
162
+ );
163
+ }
164
+ if (!name) {
165
+ throw new Error(`${pascalCase(schematic)} name is required.`);
166
+ }
167
+ if (extraPositionals.length > 0) {
168
+ throw new Error(`Unexpected argument "${extraPositionals[0]}".`);
169
+ }
170
+
171
+ assertKnownOptions(parsed, [
172
+ "crud",
173
+ "dry-run",
174
+ "flat",
175
+ "module",
176
+ "path",
177
+ "project",
178
+ "skip-import",
179
+ "spec",
180
+ "type",
181
+ ]);
182
+
183
+ const type = readStringOption(parsed, "type") ?? "rest";
184
+ if (!resourceTransports.has(type as ResourceTransport)) {
185
+ throw new Error(`Unknown resource transport "${type}".`);
186
+ }
187
+
188
+ return {
189
+ command: "generate",
190
+ schematic,
191
+ name,
192
+ dryRun: readBooleanOption(parsed, "dry-run", false),
193
+ flat: readOptionalBooleanOption(parsed, "flat"),
194
+ spec: readOptionalBooleanOption(parsed, "spec"),
195
+ skipImport: readBooleanOption(parsed, "skip-import", false),
196
+ path: readStringOption(parsed, "path"),
197
+ module: readStringOption(parsed, "module"),
198
+ project: readStringOption(parsed, "project"),
199
+ crud: readBooleanOption(parsed, "crud", true),
200
+ type: type as ResourceTransport,
46
201
  };
47
202
  }
203
+
204
+ interface ParsedOptions extends Readonly<Record<string, unknown>> {
205
+ readonly _: readonly string[];
206
+ }
207
+
208
+ function parseOptions(arguments_: readonly string[]): ParsedOptions {
209
+ return parseCliArguments([...arguments_], {
210
+ alias: {
211
+ "dry-run": ["d"],
212
+ project: ["p"],
213
+ "skip-install": ["s"],
214
+ },
215
+ boolean: ["crud", "dry-run", "flat", "skip-import", "skip-install", "spec"],
216
+ string: ["module", "path", "project", "type"],
217
+ configuration: {
218
+ "camel-case-expansion": false,
219
+ "dot-notation": false,
220
+ "duplicate-arguments-array": false,
221
+ "parse-numbers": false,
222
+ "parse-positional-numbers": false,
223
+ "short-option-groups": false,
224
+ "strip-aliased": true,
225
+ },
226
+ }) as ParsedOptions;
227
+ }
228
+
229
+ function assertKnownOptions(options: ParsedOptions, supportedOptions: readonly string[]): void {
230
+ const supported = new Set(supportedOptions);
231
+ const unknown = Object.keys(options).find((option) => option !== "_" && !supported.has(option));
232
+ if (unknown) {
233
+ throw new Error(`Unknown option "--${unknown}".`);
234
+ }
235
+ }
236
+
237
+ function readBooleanOption(options: ParsedOptions, name: string, fallback: boolean): boolean {
238
+ return readOptionalBooleanOption(options, name) ?? fallback;
239
+ }
240
+
241
+ function readOptionalBooleanOption(options: ParsedOptions, name: string): boolean | undefined {
242
+ const value = options[name];
243
+ if (value === undefined) {
244
+ return undefined;
245
+ }
246
+ if (typeof value !== "boolean") {
247
+ throw new Error(`Option "--${name}" does not accept a value.`);
248
+ }
249
+ return value;
250
+ }
251
+
252
+ function readStringOption(options: ParsedOptions, name: string): string | undefined {
253
+ const value = options[name];
254
+ if (value === undefined) {
255
+ return undefined;
256
+ }
257
+ if (typeof value !== "string") {
258
+ throw new Error(`Option "--${name}" requires a value.`);
259
+ }
260
+ return value;
261
+ }
@@ -0,0 +1,42 @@
1
+ import { camelCase, kebabCase, pascalCase } from "change-case";
2
+ import { singularize } from "inflection";
3
+ import { isAbsolute } from "node:path";
4
+
5
+ export interface ComponentNames {
6
+ readonly fileName: string;
7
+ readonly className: string;
8
+ readonly propertyName: string;
9
+ readonly singularFileName: string;
10
+ readonly singularClassName: string;
11
+ readonly routePath: string;
12
+ }
13
+
14
+ export function createComponentNames(input: string): ComponentNames {
15
+ const segments = normalizeNameSegments(input);
16
+ const fileName = segments.at(-1)!;
17
+ const singularFileName = singularize(fileName);
18
+ return {
19
+ fileName,
20
+ className: pascalCase(fileName),
21
+ propertyName: camelCase(fileName),
22
+ singularFileName,
23
+ singularClassName: pascalCase(singularFileName),
24
+ routePath: fileName,
25
+ };
26
+ }
27
+
28
+ export function normalizeNameSegments(input: string): string[] {
29
+ if (isAbsolute(input) || input.includes("..")) {
30
+ throw new Error("Generated names must be relative and cannot contain parent traversal.");
31
+ }
32
+
33
+ const segments = input
34
+ .replaceAll("\\", "/")
35
+ .split("/")
36
+ .filter(Boolean)
37
+ .map((segment) => kebabCase(segment, { locale: false }));
38
+ if (segments.length === 0 || segments.some((segment) => !/^[a-z][a-z0-9-]*$/.test(segment))) {
39
+ throw new Error("Generated names must contain letters and use kebab-case paths.");
40
+ }
41
+ return segments;
42
+ }
package/src/index.ts CHANGED
@@ -1,13 +1,27 @@
1
1
  import cliManifest from "../package.json" with { type: "json" };
2
2
  import { parseArguments } from "./arguments.ts";
3
3
  import { generateProject } from "./project-generator.ts";
4
+ import { generateSchematic } from "./schematic-generator.ts";
4
5
 
5
- export { parseArguments, type CliCommand } from "./arguments.ts";
6
+ export {
7
+ generateSchematics,
8
+ parseArguments,
9
+ type CliCommand,
10
+ type GenerateCommandOptions,
11
+ type GenerateSchematic,
12
+ type ResourceTransport,
13
+ } from "./arguments.ts";
6
14
  export {
7
15
  generateProject,
8
16
  type GenerateProjectOptions,
9
17
  type GenerateProjectResult,
10
18
  } from "./project-generator.ts";
19
+ export {
20
+ generateSchematic,
21
+ type GenerateSchematicOptions,
22
+ type GenerateSchematicResult,
23
+ type SchematicChange,
24
+ } from "./schematic-generator.ts";
11
25
 
12
26
  export async function runCli(arguments_: readonly string[]): Promise<number> {
13
27
  try {
@@ -23,6 +37,14 @@ export async function runCli(arguments_: readonly string[]): Promise<number> {
23
37
  return 0;
24
38
  }
25
39
 
40
+ if (command.command === "generate") {
41
+ const result = await generateSchematic(command);
42
+ for (const change of result.changes) {
43
+ console.log(`${change.kind} ${change.path}`);
44
+ }
45
+ return 0;
46
+ }
47
+
26
48
  const result = await generateProject(command);
27
49
  if (result.dryRun) {
28
50
  console.log(`CREATE ${result.projectDirectory}`);
@@ -47,10 +69,32 @@ const helpText = `Aponia CLI
47
69
  Usage:
48
70
  aponia new <name> [options]
49
71
  aponia n <name> [options]
72
+ aponia generate <schematic> <name> [options]
73
+ aponia g <schematic> <name> [options]
50
74
 
51
75
  Options:
52
76
  -d, --dry-run Report files without writing them
53
77
  -s, --skip-install Generate without running bun install
78
+ --flat Generate without a schematic directory
79
+ --no-flat Generate inside a schematic directory
80
+ --spec Generate spec files
81
+ --no-spec Skip spec files
82
+ --skip-import Skip declaring module registration
83
+ --module <name> Select the declaring module
84
+ --path <path> Override the configured source root
85
+ -p, --project Select a configured project
86
+ --type <type> Select a resource transport
87
+ --crud Generate CRUD entry points (default)
88
+ --no-crud Generate a resource without CRUD entry points
54
89
  -h, --help Show command help
55
90
  -v, --version Show CLI version
91
+
92
+ Schematics:
93
+ app, library (lib), class (cl), controller (co), decorator (d),
94
+ filter (f), gateway (ga), guard (gu), interface (itf),
95
+ interceptor (itc), middleware (mi), module (mo), pipe (pi),
96
+ provider (pr), resolver (r), resource (res), service (s)
97
+
98
+ Controller aliases:
99
+ router, routers, route
56
100
  `;
@@ -0,0 +1,115 @@
1
+ import {
2
+ IndentationText,
3
+ Node,
4
+ Project,
5
+ QuoteKind,
6
+ SyntaxKind,
7
+ type ObjectLiteralExpression,
8
+ type SourceFile,
9
+ } from "ts-morph";
10
+
11
+ export type ModuleRegistrationKind = "controllers" | "imports" | "providers";
12
+
13
+ export function registerInModule(
14
+ source: string,
15
+ registrationKind: ModuleRegistrationKind,
16
+ symbol: string,
17
+ importPath: string,
18
+ ): string {
19
+ const project = new Project({
20
+ useInMemoryFileSystem: true,
21
+ manipulationSettings: {
22
+ indentationText: IndentationText.TwoSpaces,
23
+ quoteKind: QuoteKind.Double,
24
+ },
25
+ });
26
+ const sourceFile = project.createSourceFile("module.ts", source);
27
+ const moduleMetadata = findModuleMetadata(sourceFile);
28
+ if (!moduleMetadata) {
29
+ throw new Error("The declaring module does not contain a @Module() metadata object.");
30
+ }
31
+
32
+ if (isAlreadyRegistered(sourceFile, registrationKind, symbol, importPath)) {
33
+ return source;
34
+ }
35
+
36
+ addImport(sourceFile, symbol, importPath);
37
+ addRegistration(moduleMetadata, registrationKind, symbol);
38
+ return sourceFile.getFullText();
39
+ }
40
+
41
+ function findModuleMetadata(sourceFile: SourceFile): ObjectLiteralExpression | undefined {
42
+ const moduleCall = sourceFile
43
+ .getDescendantsOfKind(SyntaxKind.CallExpression)
44
+ .find((callExpression) => callExpression.getExpression().getText() === "Module");
45
+ const metadata = moduleCall?.getArguments()[0];
46
+ return Node.isObjectLiteralExpression(metadata) ? metadata : undefined;
47
+ }
48
+
49
+ function isAlreadyRegistered(
50
+ sourceFile: SourceFile,
51
+ registrationKind: ModuleRegistrationKind,
52
+ symbol: string,
53
+ importPath: string,
54
+ ): boolean {
55
+ const hasImport = sourceFile
56
+ .getImportDeclarations()
57
+ .some(
58
+ (declaration) =>
59
+ declaration.getModuleSpecifierValue() === importPath &&
60
+ declaration.getNamedImports().some((namedImport) => namedImport.getName() === symbol),
61
+ );
62
+ if (!hasImport) return false;
63
+
64
+ const metadata = findModuleMetadata(sourceFile);
65
+ const registration = metadata?.getProperty(registrationKind);
66
+ if (!Node.isPropertyAssignment(registration)) return false;
67
+ const initializer = registration.getInitializer();
68
+ return (
69
+ Node.isArrayLiteralExpression(initializer) &&
70
+ initializer.getElements().some((element) => element.getText() === symbol)
71
+ );
72
+ }
73
+
74
+ function addImport(sourceFile: SourceFile, symbol: string, importPath: string): void {
75
+ const existingImport = sourceFile
76
+ .getImportDeclarations()
77
+ .find((declaration) => declaration.getModuleSpecifierValue() === importPath);
78
+ if (existingImport) {
79
+ if (!existingImport.getNamedImports().some((namedImport) => namedImport.getName() === symbol)) {
80
+ existingImport.addNamedImport(symbol);
81
+ }
82
+ return;
83
+ }
84
+
85
+ sourceFile.addImportDeclaration({
86
+ namedImports: [symbol],
87
+ moduleSpecifier: importPath,
88
+ });
89
+ }
90
+
91
+ function addRegistration(
92
+ metadata: ObjectLiteralExpression,
93
+ registrationKind: ModuleRegistrationKind,
94
+ symbol: string,
95
+ ): void {
96
+ const registration = metadata.getProperty(registrationKind);
97
+ if (!registration) {
98
+ metadata.addPropertyAssignment({
99
+ name: registrationKind,
100
+ initializer: `[${symbol}]`,
101
+ });
102
+ return;
103
+ }
104
+
105
+ if (!Node.isPropertyAssignment(registration)) {
106
+ throw new Error(`Module metadata "${registrationKind}" must be a property assignment.`);
107
+ }
108
+ const initializer = registration.getInitializer();
109
+ if (!Node.isArrayLiteralExpression(initializer)) {
110
+ throw new Error(`Module metadata "${registrationKind}" must be an array.`);
111
+ }
112
+ if (!initializer.getElements().some((element) => element.getText() === symbol)) {
113
+ initializer.addElement(symbol);
114
+ }
115
+ }