@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
|
@@ -0,0 +1,624 @@
|
|
|
1
|
+
import { pascalCase } from "change-case";
|
|
2
|
+
import findFiles from "fast-glob";
|
|
3
|
+
import { mkdir, readdir } from "node:fs/promises";
|
|
4
|
+
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
5
|
+
import type { GenerateCommandOptions, GenerateSchematic, ResourceTransport } from "./arguments.ts";
|
|
6
|
+
import {
|
|
7
|
+
createComponentNames,
|
|
8
|
+
normalizeNameSegments,
|
|
9
|
+
type ComponentNames,
|
|
10
|
+
} from "./component-names.ts";
|
|
11
|
+
import { registerInModule, type ModuleRegistrationKind } from "./module-registration.ts";
|
|
12
|
+
import { generateProject } from "./project-generator.ts";
|
|
13
|
+
|
|
14
|
+
export interface GenerateSchematicOptions extends GenerateCommandOptions {
|
|
15
|
+
readonly cwd?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface SchematicChange {
|
|
19
|
+
readonly kind: "CREATE" | "UPDATE";
|
|
20
|
+
readonly path: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface GenerateSchematicResult {
|
|
24
|
+
readonly changes: readonly SchematicChange[];
|
|
25
|
+
readonly dryRun: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface PendingFile {
|
|
29
|
+
readonly path: string;
|
|
30
|
+
readonly content: string;
|
|
31
|
+
readonly kind: "CREATE" | "UPDATE";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface AponiaConfiguration {
|
|
35
|
+
readonly sourceRoot?: string;
|
|
36
|
+
readonly generateOptions?: GenerateDefaults;
|
|
37
|
+
readonly projects?: Readonly<
|
|
38
|
+
Record<
|
|
39
|
+
string,
|
|
40
|
+
{
|
|
41
|
+
readonly root?: string;
|
|
42
|
+
readonly sourceRoot?: string;
|
|
43
|
+
readonly generateOptions?: GenerateDefaults;
|
|
44
|
+
}
|
|
45
|
+
>
|
|
46
|
+
>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface GenerateDefaults {
|
|
50
|
+
readonly flat?: boolean;
|
|
51
|
+
readonly spec?: boolean | Readonly<Partial<Record<GenerateSchematic, boolean>>>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface SchematicDefinition {
|
|
55
|
+
readonly defaultFlat: boolean;
|
|
56
|
+
readonly spec: boolean;
|
|
57
|
+
readonly suffix: string;
|
|
58
|
+
readonly registration?: ModuleRegistrationKind;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const definitions: Readonly<
|
|
62
|
+
Record<Exclude<GenerateSchematic, "app" | "library" | "resource">, SchematicDefinition>
|
|
63
|
+
> = {
|
|
64
|
+
class: { defaultFlat: true, spec: true, suffix: "" },
|
|
65
|
+
controller: {
|
|
66
|
+
defaultFlat: false,
|
|
67
|
+
spec: true,
|
|
68
|
+
suffix: "controller",
|
|
69
|
+
registration: "controllers",
|
|
70
|
+
},
|
|
71
|
+
decorator: { defaultFlat: true, spec: false, suffix: "decorator" },
|
|
72
|
+
filter: { defaultFlat: true, spec: true, suffix: "filter" },
|
|
73
|
+
gateway: { defaultFlat: true, spec: true, suffix: "gateway", registration: "providers" },
|
|
74
|
+
guard: { defaultFlat: true, spec: true, suffix: "guard" },
|
|
75
|
+
interface: { defaultFlat: true, spec: false, suffix: "interface" },
|
|
76
|
+
interceptor: { defaultFlat: true, spec: true, suffix: "interceptor" },
|
|
77
|
+
middleware: { defaultFlat: true, spec: true, suffix: "middleware" },
|
|
78
|
+
module: { defaultFlat: false, spec: false, suffix: "module", registration: "imports" },
|
|
79
|
+
pipe: { defaultFlat: true, spec: true, suffix: "pipe" },
|
|
80
|
+
provider: { defaultFlat: true, spec: true, suffix: "", registration: "providers" },
|
|
81
|
+
resolver: {
|
|
82
|
+
defaultFlat: false,
|
|
83
|
+
spec: true,
|
|
84
|
+
suffix: "resolver",
|
|
85
|
+
registration: "providers",
|
|
86
|
+
},
|
|
87
|
+
service: {
|
|
88
|
+
defaultFlat: false,
|
|
89
|
+
spec: true,
|
|
90
|
+
suffix: "service",
|
|
91
|
+
registration: "providers",
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
export async function generateSchematic(
|
|
96
|
+
options: GenerateSchematicOptions,
|
|
97
|
+
): Promise<GenerateSchematicResult> {
|
|
98
|
+
const projectRoot = resolve(options.cwd ?? process.cwd());
|
|
99
|
+
const names = createComponentNames(options.name);
|
|
100
|
+
|
|
101
|
+
if (options.schematic === "app") {
|
|
102
|
+
return generateApplication(projectRoot, names.fileName, options);
|
|
103
|
+
}
|
|
104
|
+
if (options.schematic === "library") {
|
|
105
|
+
return generateLibrary(projectRoot, names, options);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const configuration = await readConfiguration(projectRoot);
|
|
109
|
+
const project = resolveProject(configuration, options.project);
|
|
110
|
+
const sourceRoot = resolveInside(
|
|
111
|
+
projectRoot,
|
|
112
|
+
project.sourceRoot ?? configuration.sourceRoot ?? "src",
|
|
113
|
+
);
|
|
114
|
+
const basePath = options.path ? resolveInside(projectRoot, options.path) : sourceRoot;
|
|
115
|
+
const spec =
|
|
116
|
+
options.spec ??
|
|
117
|
+
readSpecDefault(project.generateOptions?.spec, options.schematic) ??
|
|
118
|
+
readSpecDefault(configuration.generateOptions?.spec, options.schematic) ??
|
|
119
|
+
true;
|
|
120
|
+
const effectiveOptions: GenerateSchematicOptions = {
|
|
121
|
+
...options,
|
|
122
|
+
flat: options.flat ?? project.generateOptions?.flat ?? configuration.generateOptions?.flat,
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const files =
|
|
126
|
+
options.schematic === "resource"
|
|
127
|
+
? createResourceFiles(basePath, names, effectiveOptions, spec)
|
|
128
|
+
: createComponentFiles(basePath, names, effectiveOptions, spec);
|
|
129
|
+
|
|
130
|
+
const registration = registrationFor(options.schematic);
|
|
131
|
+
if (registration && !options.skipImport) {
|
|
132
|
+
const primary = primaryFile(files, options.schematic);
|
|
133
|
+
const moduleFile = await findModuleFile({
|
|
134
|
+
projectRoot,
|
|
135
|
+
sourceRoot,
|
|
136
|
+
fromDirectory: dirname(primary.path),
|
|
137
|
+
requestedModule: options.module,
|
|
138
|
+
excludedFile: options.schematic === "module" ? primary.path : undefined,
|
|
139
|
+
});
|
|
140
|
+
if (moduleFile) {
|
|
141
|
+
const original = await Bun.file(moduleFile).text();
|
|
142
|
+
const symbol = symbolFor(options.schematic, names);
|
|
143
|
+
const importPath = toImportPath(moduleFile, primary.path);
|
|
144
|
+
const updated = registerInModule(original, registration, symbol, importPath);
|
|
145
|
+
if (updated !== original) {
|
|
146
|
+
files.push({ path: moduleFile, content: updated, kind: "UPDATE" });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return writePendingFiles(projectRoot, files, options.dryRun);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function createComponentFiles(
|
|
155
|
+
basePath: string,
|
|
156
|
+
names: ComponentNames,
|
|
157
|
+
options: GenerateSchematicOptions,
|
|
158
|
+
specEnabled: boolean,
|
|
159
|
+
): PendingFile[] {
|
|
160
|
+
const schematic = options.schematic as Exclude<GenerateSchematic, "app" | "library" | "resource">;
|
|
161
|
+
const definition = definitions[schematic];
|
|
162
|
+
const flat = options.flat ?? definition.defaultFlat;
|
|
163
|
+
const parentSegments = normalizeNameSegments(options.name).slice(0, -1);
|
|
164
|
+
const directory = join(basePath, ...parentSegments, ...(flat ? [] : [names.fileName]));
|
|
165
|
+
const stem = definition.suffix ? `${names.fileName}.${definition.suffix}` : names.fileName;
|
|
166
|
+
const files: PendingFile[] = [
|
|
167
|
+
{
|
|
168
|
+
path: join(directory, `${stem}.ts`),
|
|
169
|
+
content: renderComponent(schematic, names),
|
|
170
|
+
kind: "CREATE",
|
|
171
|
+
},
|
|
172
|
+
];
|
|
173
|
+
|
|
174
|
+
if (definition.spec && specEnabled) {
|
|
175
|
+
files.push({
|
|
176
|
+
path: join(directory, `${stem}.spec.ts`),
|
|
177
|
+
content: renderComponentSpec(schematic, names, stem),
|
|
178
|
+
kind: "CREATE",
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return files;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function createResourceFiles(
|
|
186
|
+
basePath: string,
|
|
187
|
+
names: ComponentNames,
|
|
188
|
+
options: GenerateSchematicOptions,
|
|
189
|
+
specEnabled: boolean,
|
|
190
|
+
): PendingFile[] {
|
|
191
|
+
const flat = options.flat ?? false;
|
|
192
|
+
const parentSegments = normalizeNameSegments(options.name).slice(0, -1);
|
|
193
|
+
const directory = join(basePath, ...parentSegments, ...(flat ? [] : [names.fileName]));
|
|
194
|
+
const transportStem = resourceTransportStem(options.type);
|
|
195
|
+
const dtoSuffix = options.type.startsWith("graphql") ? "input" : "dto";
|
|
196
|
+
const files: PendingFile[] = [
|
|
197
|
+
createFile(
|
|
198
|
+
directory,
|
|
199
|
+
`${names.fileName}.module.ts`,
|
|
200
|
+
renderResourceModule(names, transportStem),
|
|
201
|
+
),
|
|
202
|
+
createFile(
|
|
203
|
+
directory,
|
|
204
|
+
`${names.fileName}.service.ts`,
|
|
205
|
+
renderResourceService(names, options.crud, dtoSuffix),
|
|
206
|
+
),
|
|
207
|
+
];
|
|
208
|
+
|
|
209
|
+
if (options.crud) {
|
|
210
|
+
files.push(
|
|
211
|
+
createFile(
|
|
212
|
+
join(directory, "dto"),
|
|
213
|
+
`create-${names.singularFileName}.${dtoSuffix}.ts`,
|
|
214
|
+
renderCreateDto(names, dtoSuffix),
|
|
215
|
+
),
|
|
216
|
+
createFile(
|
|
217
|
+
join(directory, "dto"),
|
|
218
|
+
`update-${names.singularFileName}.${dtoSuffix}.ts`,
|
|
219
|
+
renderUpdateDto(names, dtoSuffix),
|
|
220
|
+
),
|
|
221
|
+
createFile(
|
|
222
|
+
join(directory, "entities"),
|
|
223
|
+
`${names.singularFileName}.entity.ts`,
|
|
224
|
+
renderEntity(names),
|
|
225
|
+
),
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (options.type === "rest") {
|
|
230
|
+
files.push(
|
|
231
|
+
createFile(
|
|
232
|
+
directory,
|
|
233
|
+
`${names.fileName}.controller.ts`,
|
|
234
|
+
renderResourceController(names, options.crud),
|
|
235
|
+
),
|
|
236
|
+
);
|
|
237
|
+
} else {
|
|
238
|
+
files.push(
|
|
239
|
+
createFile(
|
|
240
|
+
directory,
|
|
241
|
+
`${names.fileName}.${transportStem}.ts`,
|
|
242
|
+
renderResourceTransport(names, options.type, options.crud),
|
|
243
|
+
),
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (specEnabled) {
|
|
248
|
+
files.push(
|
|
249
|
+
createFile(
|
|
250
|
+
directory,
|
|
251
|
+
`${names.fileName}.service.spec.ts`,
|
|
252
|
+
options.crud
|
|
253
|
+
? renderResourceServiceSpec(names)
|
|
254
|
+
: renderSimpleSpec(`./${names.fileName}.service.ts`, `${names.className}Service`),
|
|
255
|
+
),
|
|
256
|
+
createFile(
|
|
257
|
+
directory,
|
|
258
|
+
`${names.fileName}.${transportStem}.spec.ts`,
|
|
259
|
+
renderResourceTransportSpec(names, transportStem),
|
|
260
|
+
),
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return files;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async function generateApplication(
|
|
268
|
+
projectRoot: string,
|
|
269
|
+
name: string,
|
|
270
|
+
options: GenerateSchematicOptions,
|
|
271
|
+
): Promise<GenerateSchematicResult> {
|
|
272
|
+
const appsDirectory = join(projectRoot, "apps");
|
|
273
|
+
if (!options.dryRun) {
|
|
274
|
+
await mkdir(appsDirectory, { recursive: true });
|
|
275
|
+
}
|
|
276
|
+
const result = await generateProject({
|
|
277
|
+
name,
|
|
278
|
+
cwd: appsDirectory,
|
|
279
|
+
dryRun: options.dryRun,
|
|
280
|
+
skipInstall: true,
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
return {
|
|
284
|
+
dryRun: options.dryRun,
|
|
285
|
+
changes: result.files.map((file) => ({
|
|
286
|
+
kind: "CREATE",
|
|
287
|
+
path: join("apps", name, file),
|
|
288
|
+
})),
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async function generateLibrary(
|
|
293
|
+
projectRoot: string,
|
|
294
|
+
names: ComponentNames,
|
|
295
|
+
options: GenerateSchematicOptions,
|
|
296
|
+
): Promise<GenerateSchematicResult> {
|
|
297
|
+
const directory = join(projectRoot, "packages", names.fileName);
|
|
298
|
+
const files: PendingFile[] = [
|
|
299
|
+
createFile(
|
|
300
|
+
directory,
|
|
301
|
+
"package.json",
|
|
302
|
+
`${JSON.stringify(
|
|
303
|
+
{
|
|
304
|
+
name: names.fileName,
|
|
305
|
+
version: "0.0.0",
|
|
306
|
+
private: true,
|
|
307
|
+
type: "module",
|
|
308
|
+
scripts: {
|
|
309
|
+
build: "bun build ./src/index.ts --outdir ./dist --target bun",
|
|
310
|
+
test: "bun test",
|
|
311
|
+
},
|
|
312
|
+
dependencies: {
|
|
313
|
+
"@aponiajs/common": "latest",
|
|
314
|
+
},
|
|
315
|
+
},
|
|
316
|
+
undefined,
|
|
317
|
+
2,
|
|
318
|
+
)}\n`,
|
|
319
|
+
),
|
|
320
|
+
createFile(
|
|
321
|
+
join(directory, "src"),
|
|
322
|
+
`${names.fileName}.module.ts`,
|
|
323
|
+
`import { Module } from "@aponiajs/common";\n\n@Module({})\nexport class ${names.className}Module {}\n`,
|
|
324
|
+
),
|
|
325
|
+
createFile(
|
|
326
|
+
join(directory, "src"),
|
|
327
|
+
`${names.fileName}.service.ts`,
|
|
328
|
+
`import { Injectable } from "@aponiajs/common";\n\n@Injectable()\nexport class ${names.className}Service {}\n`,
|
|
329
|
+
),
|
|
330
|
+
createFile(
|
|
331
|
+
join(directory, "src"),
|
|
332
|
+
`${names.fileName}.service.spec.ts`,
|
|
333
|
+
renderSimpleSpec(`./${names.fileName}.service.ts`, `${names.className}Service`),
|
|
334
|
+
),
|
|
335
|
+
createFile(
|
|
336
|
+
join(directory, "src"),
|
|
337
|
+
"index.ts",
|
|
338
|
+
`export { ${names.className}Module } from "./${names.fileName}.module.ts";\nexport { ${names.className}Service } from "./${names.fileName}.service.ts";\n`,
|
|
339
|
+
),
|
|
340
|
+
];
|
|
341
|
+
|
|
342
|
+
return writePendingFiles(projectRoot, files, options.dryRun);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
async function writePendingFiles(
|
|
346
|
+
projectRoot: string,
|
|
347
|
+
files: readonly PendingFile[],
|
|
348
|
+
dryRun: boolean,
|
|
349
|
+
): Promise<GenerateSchematicResult> {
|
|
350
|
+
const creates = files.filter((file) => file.kind === "CREATE");
|
|
351
|
+
for (const file of creates) {
|
|
352
|
+
if (await Bun.file(file.path).exists()) {
|
|
353
|
+
throw new Error(`File "${relative(projectRoot, file.path)}" already exists.`);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if (!dryRun) {
|
|
358
|
+
for (const file of files) {
|
|
359
|
+
await mkdir(dirname(file.path), { recursive: true });
|
|
360
|
+
await Bun.write(file.path, file.content);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return {
|
|
365
|
+
dryRun,
|
|
366
|
+
changes: files.map((file) => ({
|
|
367
|
+
kind: file.kind,
|
|
368
|
+
path: relative(projectRoot, file.path),
|
|
369
|
+
})),
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function renderComponent(schematic: keyof typeof definitions, names: ComponentNames): string {
|
|
374
|
+
const className = `${names.className}${classSuffix(schematic)}`;
|
|
375
|
+
switch (schematic) {
|
|
376
|
+
case "controller":
|
|
377
|
+
return `import { Controller } from "@aponiajs/common";\n\n@Controller("${names.routePath}")\nexport class ${className} {}\n`;
|
|
378
|
+
case "module":
|
|
379
|
+
return `import { Module } from "@aponiajs/common";\n\n@Module({})\nexport class ${className} {}\n`;
|
|
380
|
+
case "service":
|
|
381
|
+
case "provider":
|
|
382
|
+
return `import { Injectable } from "@aponiajs/common";\n\n@Injectable()\nexport class ${className} {}\n`;
|
|
383
|
+
case "decorator":
|
|
384
|
+
return `export function ${className}(): MethodDecorator {\n return () => undefined;\n}\n`;
|
|
385
|
+
case "interface":
|
|
386
|
+
return `export interface ${className} {}\n`;
|
|
387
|
+
case "guard":
|
|
388
|
+
return `export class ${className} {\n canActivate(): boolean {\n return true;\n }\n}\n`;
|
|
389
|
+
case "interceptor":
|
|
390
|
+
return `export class ${className} {\n intercept<T>(next: () => T): T {\n return next();\n }\n}\n`;
|
|
391
|
+
case "middleware":
|
|
392
|
+
return `export class ${className} {\n async use(request: Request, next: () => Response | Promise<Response>): Promise<Response> {\n void request;\n return next();\n }\n}\n`;
|
|
393
|
+
case "pipe":
|
|
394
|
+
return `export class ${className} {\n transform<T>(value: T): T {\n return value;\n }\n}\n`;
|
|
395
|
+
case "filter":
|
|
396
|
+
return `export class ${className} {\n catch(error: unknown): unknown {\n return error;\n }\n}\n`;
|
|
397
|
+
default:
|
|
398
|
+
return `export class ${className} {}\n`;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function renderComponentSpec(
|
|
403
|
+
schematic: keyof typeof definitions,
|
|
404
|
+
names: ComponentNames,
|
|
405
|
+
stem: string,
|
|
406
|
+
): string {
|
|
407
|
+
return renderSimpleSpec(`./${stem}.ts`, `${names.className}${classSuffix(schematic)}`);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function renderSimpleSpec(importPath: string, className: string): string {
|
|
411
|
+
return `import { expect, test } from "bun:test";\nimport { ${className} } from "${importPath}";\n\ntest("${className} is defined", () => {\n expect(new ${className}()).toBeDefined();\n});\n`;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function renderResourceModule(names: ComponentNames, transportStem: string): string {
|
|
415
|
+
const transportClass = `${names.className}${pascalCase(transportStem)}`;
|
|
416
|
+
const metadata =
|
|
417
|
+
transportStem === "controller"
|
|
418
|
+
? `controllers: [${transportClass}],\n providers: [${names.className}Service],`
|
|
419
|
+
: `providers: [${transportClass}, ${names.className}Service],`;
|
|
420
|
+
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`;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function renderResourceController(names: ComponentNames, crud: boolean): string {
|
|
424
|
+
if (!crud) {
|
|
425
|
+
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`;
|
|
426
|
+
}
|
|
427
|
+
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`;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function renderResourceService(
|
|
431
|
+
names: ComponentNames,
|
|
432
|
+
crud: boolean,
|
|
433
|
+
dtoSuffix: "dto" | "input",
|
|
434
|
+
): string {
|
|
435
|
+
if (!crud) {
|
|
436
|
+
return `import { Injectable } from "@aponiajs/common";\n\n@Injectable()\nexport class ${names.className}Service {}\n`;
|
|
437
|
+
}
|
|
438
|
+
const typeSuffix = pascalCase(dtoSuffix);
|
|
439
|
+
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`;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function renderCreateDto(names: ComponentNames, suffix: "dto" | "input"): string {
|
|
443
|
+
const typeSuffix = pascalCase(suffix);
|
|
444
|
+
return `export class Create${names.singularClassName}${typeSuffix} {\n name = "";\n}\n`;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function renderUpdateDto(names: ComponentNames, suffix: "dto" | "input"): string {
|
|
448
|
+
const typeSuffix = pascalCase(suffix);
|
|
449
|
+
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`;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function renderEntity(names: ComponentNames): string {
|
|
453
|
+
return `export class ${names.singularClassName} {\n id = "";\n name = "";\n}\n`;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function renderResourceTransport(
|
|
457
|
+
names: ComponentNames,
|
|
458
|
+
type: ResourceTransport,
|
|
459
|
+
crud: boolean,
|
|
460
|
+
): string {
|
|
461
|
+
const stem = resourceTransportStem(type);
|
|
462
|
+
const className = `${names.className}${pascalCase(stem)}`;
|
|
463
|
+
const method = crud
|
|
464
|
+
? `\n findAll() {\n return this.${names.propertyName}Service.findAll();\n }\n`
|
|
465
|
+
: "";
|
|
466
|
+
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`;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function renderResourceServiceSpec(names: ComponentNames): string {
|
|
470
|
+
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`;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function renderResourceTransportSpec(names: ComponentNames, stem: string): string {
|
|
474
|
+
const className = `${names.className}${pascalCase(stem)}`;
|
|
475
|
+
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`;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function classSuffix(schematic: keyof typeof definitions): string {
|
|
479
|
+
if (schematic === "class" || schematic === "provider" || schematic === "interface") {
|
|
480
|
+
return "";
|
|
481
|
+
}
|
|
482
|
+
return pascalCase(schematic);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function symbolFor(schematic: GenerateSchematic, names: ComponentNames): string {
|
|
486
|
+
if (schematic === "resource") return `${names.className}Module`;
|
|
487
|
+
return `${names.className}${classSuffix(schematic as keyof typeof definitions)}`;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function registrationFor(schematic: GenerateSchematic): ModuleRegistrationKind | undefined {
|
|
491
|
+
if (schematic === "resource") return "imports";
|
|
492
|
+
if (schematic === "app" || schematic === "library") return undefined;
|
|
493
|
+
return definitions[schematic].registration;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function primaryFile(files: readonly PendingFile[], schematic: GenerateSchematic): PendingFile {
|
|
497
|
+
const expectedSuffix =
|
|
498
|
+
schematic === "resource"
|
|
499
|
+
? ".module.ts"
|
|
500
|
+
: definitions[schematic as keyof typeof definitions].suffix
|
|
501
|
+
? `.${definitions[schematic as keyof typeof definitions].suffix}.ts`
|
|
502
|
+
: ".ts";
|
|
503
|
+
return files.find(
|
|
504
|
+
(file) => file.path.endsWith(expectedSuffix) && !file.path.endsWith(".spec.ts"),
|
|
505
|
+
)!;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function resourceTransportStem(type: ResourceTransport): "controller" | "gateway" | "resolver" {
|
|
509
|
+
if (type === "ws") return "gateway";
|
|
510
|
+
if (type.startsWith("graphql")) return "resolver";
|
|
511
|
+
return "controller";
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function createFile(directory: string, name: string, content: string): PendingFile {
|
|
515
|
+
return { path: join(directory, name), content, kind: "CREATE" };
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
async function readConfiguration(projectRoot: string): Promise<AponiaConfiguration> {
|
|
519
|
+
const file = Bun.file(join(projectRoot, "aponia.json"));
|
|
520
|
+
if (!(await file.exists())) {
|
|
521
|
+
throw new Error('Could not find "aponia.json". Run the command from an Aponia project root.');
|
|
522
|
+
}
|
|
523
|
+
return (await file.json()) as AponiaConfiguration;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function resolveProject(
|
|
527
|
+
configuration: AponiaConfiguration,
|
|
528
|
+
requestedProject: string | undefined,
|
|
529
|
+
): {
|
|
530
|
+
readonly root?: string;
|
|
531
|
+
readonly sourceRoot?: string;
|
|
532
|
+
readonly generateOptions?: GenerateDefaults;
|
|
533
|
+
} {
|
|
534
|
+
if (!requestedProject) return {};
|
|
535
|
+
const project = configuration.projects?.[requestedProject];
|
|
536
|
+
if (!project) {
|
|
537
|
+
throw new Error(`Unknown project "${requestedProject}" in aponia.json.`);
|
|
538
|
+
}
|
|
539
|
+
return {
|
|
540
|
+
root: project.root,
|
|
541
|
+
sourceRoot: project.sourceRoot ?? join(project.root ?? "", "src"),
|
|
542
|
+
generateOptions: project.generateOptions,
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function readSpecDefault(
|
|
547
|
+
value: GenerateDefaults["spec"],
|
|
548
|
+
schematic: GenerateSchematic,
|
|
549
|
+
): boolean | undefined {
|
|
550
|
+
if (typeof value === "boolean" || value === undefined) return value;
|
|
551
|
+
return value[schematic];
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function resolveInside(root: string, path: string): string {
|
|
555
|
+
const resolved = resolve(root, path);
|
|
556
|
+
if (resolved !== root && !resolved.startsWith(`${root}${sep}`)) {
|
|
557
|
+
throw new Error(`Path "${path}" escapes the project root.`);
|
|
558
|
+
}
|
|
559
|
+
return resolved;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
async function findModuleFile(options: {
|
|
563
|
+
readonly projectRoot: string;
|
|
564
|
+
readonly sourceRoot: string;
|
|
565
|
+
readonly fromDirectory: string;
|
|
566
|
+
readonly requestedModule?: string;
|
|
567
|
+
readonly excludedFile?: string;
|
|
568
|
+
}): Promise<string | undefined> {
|
|
569
|
+
if (options.requestedModule) {
|
|
570
|
+
const candidates = await listModuleFiles(options.sourceRoot);
|
|
571
|
+
const normalized = options.requestedModule.replace(/\.module(?:\.ts)?$/, "");
|
|
572
|
+
const matches = candidates.filter((file) => {
|
|
573
|
+
const relativeFile = relative(options.sourceRoot, file).replaceAll("\\", "/");
|
|
574
|
+
return (
|
|
575
|
+
relativeFile === `${normalized}.module.ts` ||
|
|
576
|
+
relativeFile.endsWith(`/${normalized}.module.ts`)
|
|
577
|
+
);
|
|
578
|
+
});
|
|
579
|
+
if (matches.length !== 1) {
|
|
580
|
+
throw new Error(
|
|
581
|
+
matches.length === 0
|
|
582
|
+
? `Could not find module "${options.requestedModule}".`
|
|
583
|
+
: `Module "${options.requestedModule}" is ambiguous.`,
|
|
584
|
+
);
|
|
585
|
+
}
|
|
586
|
+
return matches[0];
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
let directory = options.fromDirectory;
|
|
590
|
+
while (directory === options.sourceRoot || directory.startsWith(`${options.sourceRoot}${sep}`)) {
|
|
591
|
+
const entries = await safeReadDirectory(directory);
|
|
592
|
+
const modules = entries
|
|
593
|
+
.filter((entry) => entry.endsWith(".module.ts"))
|
|
594
|
+
.map((entry) => join(directory, entry))
|
|
595
|
+
.filter((file) => file !== options.excludedFile)
|
|
596
|
+
.toSorted();
|
|
597
|
+
if (modules.length > 0) return modules[0];
|
|
598
|
+
if (directory === options.sourceRoot) break;
|
|
599
|
+
directory = dirname(directory);
|
|
600
|
+
}
|
|
601
|
+
return undefined;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
async function listModuleFiles(directory: string): Promise<string[]> {
|
|
605
|
+
return findFiles("**/*.module.ts", {
|
|
606
|
+
absolute: true,
|
|
607
|
+
cwd: directory,
|
|
608
|
+
onlyFiles: true,
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
async function safeReadDirectory(directory: string): Promise<string[]> {
|
|
613
|
+
try {
|
|
614
|
+
return await readdir(directory);
|
|
615
|
+
} catch {
|
|
616
|
+
return [];
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function toImportPath(moduleFile: string, generatedFile: string): string {
|
|
621
|
+
let path = relative(dirname(moduleFile), generatedFile).replaceAll("\\", "/");
|
|
622
|
+
if (!path.startsWith(".")) path = `./${path}`;
|
|
623
|
+
return path;
|
|
624
|
+
}
|