@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/README.md +14 -3
- package/dist/index.d.mts +33 -2
- package/dist/index.mjs +623 -15
- package/package.json +11 -1
- package/src/arguments.ts +226 -12
- package/src/component-names.ts +42 -0
- package/src/index.ts +45 -1
- package/src/module-registration.ts +115 -0
- package/src/schematic-generator.ts +624 -0
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.
|
|
10
|
+
var version = "0.2.2";
|
|
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
|
|
13
|
-
|
|
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
|
-
|
|
16
|
-
|
|
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:
|
|
27
|
-
skipInstall:
|
|
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 };
|