@avijs/cli 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +20 -2
- package/dist/bin.d.ts +3 -0
- package/dist/bin.d.ts.map +1 -0
- package/dist/bin.js +4 -0
- package/dist/bin.js.map +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +154 -0
- package/dist/cli.js.map +1 -0
- package/dist/generate.d.ts +3 -0
- package/dist/generate.d.ts.map +1 -0
- package/dist/generate.js +308 -0
- package/dist/generate.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/templates.d.ts +16 -0
- package/dist/templates.d.ts.map +1 -0
- package/dist/templates.js +310 -0
- package/dist/templates.js.map +1 -0
- package/package.json +52 -3
- package/src/bin.ts +4 -0
- package/src/cli.ts +160 -0
- package/src/generate.ts +394 -0
- package/src/index.ts +3 -0
- package/src/templates.ts +333 -0
package/src/generate.ts
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { parseArgs } from 'node:util';
|
|
5
|
+
import ts from 'typescript';
|
|
6
|
+
|
|
7
|
+
type Kind = 'module' | 'controller' | 'service';
|
|
8
|
+
type Layout = 'modular' | 'minimal';
|
|
9
|
+
|
|
10
|
+
const KINDS: Record<string, Kind> = {
|
|
11
|
+
module: 'module',
|
|
12
|
+
mo: 'module',
|
|
13
|
+
controller: 'controller',
|
|
14
|
+
co: 'controller',
|
|
15
|
+
service: 'service',
|
|
16
|
+
s: 'service',
|
|
17
|
+
};
|
|
18
|
+
const SEGMENT = /^[a-z][a-z0-9-]*$/;
|
|
19
|
+
|
|
20
|
+
export const GENERATE_USAGE = ` avi generate <module|controller|service> <path> [--root .] [--dry-run]
|
|
21
|
+
Create a module, controller or service and register it. Short: avi g co admin/posts.
|
|
22
|
+
Modular projects get src/modules/<path>/<name>.<kind>.ts, registered in the nearest
|
|
23
|
+
module; minimal projects get src/routes/<path>.ts or src/services/<path>.ts, registered
|
|
24
|
+
in src/app.ts.`;
|
|
25
|
+
|
|
26
|
+
interface Names {
|
|
27
|
+
/** kebab-case file name, e.g. `user-profile`. */
|
|
28
|
+
readonly file: string;
|
|
29
|
+
/** PascalCase, e.g. `UserProfile`. */
|
|
30
|
+
readonly pascal: string;
|
|
31
|
+
/** camelCase, e.g. `userProfile`. */
|
|
32
|
+
readonly camel: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Files to write, keyed by absolute path. Reads fall back to disk. */
|
|
36
|
+
class Plan {
|
|
37
|
+
readonly #files = new Map<string, { content: string; created: boolean }>();
|
|
38
|
+
readonly notes: string[] = [];
|
|
39
|
+
|
|
40
|
+
exists(file: string): boolean {
|
|
41
|
+
return this.#files.has(file) || existsSync(file);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async read(file: string): Promise<string> {
|
|
45
|
+
return this.#files.get(file)?.content ?? (await readFile(file, 'utf8'));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
create(file: string, content: string): void {
|
|
49
|
+
if (this.exists(file)) throw new GenerateError(`${file} already exists`);
|
|
50
|
+
this.#files.set(file, { content, created: true });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
update(file: string, content: string): void {
|
|
54
|
+
const existing = this.#files.get(file);
|
|
55
|
+
this.#files.set(file, { content, created: existing?.created ?? false });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async apply(root: string, dryRun: boolean): Promise<void> {
|
|
59
|
+
for (const [file, { content, created }] of this.#files) {
|
|
60
|
+
console.log(
|
|
61
|
+
`${created ? 'CREATE' : 'UPDATE'} ${path.relative(root, file).replaceAll('\\', '/')}`,
|
|
62
|
+
);
|
|
63
|
+
if (dryRun) continue;
|
|
64
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
65
|
+
await writeFile(file, content);
|
|
66
|
+
}
|
|
67
|
+
for (const note of this.notes) console.log(note);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
class GenerateError extends Error {}
|
|
72
|
+
|
|
73
|
+
export async function generate(args: readonly string[]): Promise<number> {
|
|
74
|
+
const { values, positionals } = parseArgs({
|
|
75
|
+
args: [...args],
|
|
76
|
+
allowPositionals: true,
|
|
77
|
+
options: {
|
|
78
|
+
root: { type: 'string', default: '.' },
|
|
79
|
+
'dry-run': { type: 'boolean', default: false },
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
const [kindArg, target] = positionals;
|
|
83
|
+
const kind = kindArg === undefined ? undefined : KINDS[kindArg];
|
|
84
|
+
if (!kind || !target) {
|
|
85
|
+
console.error(`Usage:\n${GENERATE_USAGE}`);
|
|
86
|
+
return 2;
|
|
87
|
+
}
|
|
88
|
+
const segments = target.split('/').filter(Boolean);
|
|
89
|
+
const bad = segments.find((segment) => !SEGMENT.test(segment));
|
|
90
|
+
if (segments.length === 0 || bad !== undefined) {
|
|
91
|
+
console.error(
|
|
92
|
+
`Invalid path "${target}": use lowercase names with letters, digits and "-", separated by "/"`,
|
|
93
|
+
);
|
|
94
|
+
return 2;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const root = path.resolve(values.root);
|
|
98
|
+
const src = path.join(root, 'src');
|
|
99
|
+
const layout: Layout | undefined = existsSync(path.join(src, 'app.module.ts'))
|
|
100
|
+
? 'modular'
|
|
101
|
+
: existsSync(path.join(src, 'app.ts'))
|
|
102
|
+
? 'minimal'
|
|
103
|
+
: undefined;
|
|
104
|
+
if (!layout) {
|
|
105
|
+
console.error(
|
|
106
|
+
`No src/app.module.ts or src/app.ts in ${root}. Run avi generate in an Avi.js project, or pass --root.`,
|
|
107
|
+
);
|
|
108
|
+
return 1;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const names = toNames(segments.at(-1) as string);
|
|
112
|
+
const dirs = segments.slice(0, -1);
|
|
113
|
+
const plan = new Plan();
|
|
114
|
+
try {
|
|
115
|
+
if (layout === 'modular') await modular(plan, src, kind, dirs, names);
|
|
116
|
+
else await minimal(plan, src, kind, dirs, names);
|
|
117
|
+
} catch (error) {
|
|
118
|
+
if (!(error instanceof GenerateError)) throw error;
|
|
119
|
+
console.error(error.message);
|
|
120
|
+
return 1;
|
|
121
|
+
}
|
|
122
|
+
await plan.apply(root, values['dry-run']);
|
|
123
|
+
return 0;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function modular(
|
|
127
|
+
plan: Plan,
|
|
128
|
+
src: string,
|
|
129
|
+
kind: Kind,
|
|
130
|
+
dirs: readonly string[],
|
|
131
|
+
names: Names,
|
|
132
|
+
): Promise<void> {
|
|
133
|
+
const folder = path.join(src, 'modules', ...dirs, names.file);
|
|
134
|
+
const file = path.join(folder, `${names.file}.${kind}.ts`);
|
|
135
|
+
const className = `${names.pascal}${capitalize(kind)}`;
|
|
136
|
+
|
|
137
|
+
if (kind === 'module') {
|
|
138
|
+
plan.create(
|
|
139
|
+
file,
|
|
140
|
+
`import { Module } from '@avijs/core';\n\n@Module({})\nexport class ${className} {}\n`,
|
|
141
|
+
);
|
|
142
|
+
await register(
|
|
143
|
+
plan,
|
|
144
|
+
nearestModule(plan, src, path.dirname(folder)),
|
|
145
|
+
'imports',
|
|
146
|
+
className,
|
|
147
|
+
file,
|
|
148
|
+
);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (kind === 'service') {
|
|
153
|
+
plan.create(
|
|
154
|
+
file,
|
|
155
|
+
`import { Injectable } from '@avijs/core';
|
|
156
|
+
|
|
157
|
+
@Injectable()
|
|
158
|
+
export class ${className} {
|
|
159
|
+
findAll(): unknown[] {
|
|
160
|
+
return [];
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
`,
|
|
164
|
+
);
|
|
165
|
+
await register(plan, nearestModule(plan, src, folder), 'providers', className, file);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const service = path.join(folder, `${names.file}.service.ts`);
|
|
170
|
+
const serviceClass = `${names.pascal}Service`;
|
|
171
|
+
plan.create(
|
|
172
|
+
file,
|
|
173
|
+
plan.exists(service)
|
|
174
|
+
? `import { Controller, Get } from '@avijs/core';
|
|
175
|
+
import { ${serviceClass} } from './${names.file}.service.js';
|
|
176
|
+
|
|
177
|
+
@Controller('/${names.file}')
|
|
178
|
+
export class ${className} {
|
|
179
|
+
static inject = [${serviceClass}] as const;
|
|
180
|
+
|
|
181
|
+
constructor(private readonly ${names.camel}: ${serviceClass}) {}
|
|
182
|
+
|
|
183
|
+
@Get()
|
|
184
|
+
findAll() {
|
|
185
|
+
return this.${names.camel}.findAll();
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
`
|
|
189
|
+
: `import { Controller, Get } from '@avijs/core';
|
|
190
|
+
|
|
191
|
+
@Controller('/${names.file}')
|
|
192
|
+
export class ${className} {
|
|
193
|
+
@Get()
|
|
194
|
+
findAll() {
|
|
195
|
+
return [];
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
`,
|
|
199
|
+
);
|
|
200
|
+
await register(plan, nearestModule(plan, src, folder), 'controllers', className, file);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function minimal(
|
|
204
|
+
plan: Plan,
|
|
205
|
+
src: string,
|
|
206
|
+
kind: Kind,
|
|
207
|
+
dirs: readonly string[],
|
|
208
|
+
names: Names,
|
|
209
|
+
): Promise<void> {
|
|
210
|
+
if (kind === 'module') {
|
|
211
|
+
throw new GenerateError(
|
|
212
|
+
'This is a minimal project (src/app.ts, no src/app.module.ts), which does not use modules. Generate a controller or service, or see the project structure chapter to move to modules.',
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
const app = path.join(src, 'app.ts');
|
|
216
|
+
const service = path.join(src, 'services', ...dirs, `${names.file}.ts`);
|
|
217
|
+
const serviceClass = `${names.pascal}Service`;
|
|
218
|
+
|
|
219
|
+
if (kind === 'service') {
|
|
220
|
+
plan.create(
|
|
221
|
+
service,
|
|
222
|
+
`export class ${serviceClass} {
|
|
223
|
+
findAll(): unknown[] {
|
|
224
|
+
return [];
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
`,
|
|
228
|
+
);
|
|
229
|
+
await register(plan, app, 'providers', serviceClass, service);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const file = path.join(src, 'routes', ...dirs, `${names.file}.ts`);
|
|
234
|
+
const exportName = `${names.camel}Routes`;
|
|
235
|
+
const serviceImport = importPath(file, service);
|
|
236
|
+
plan.create(
|
|
237
|
+
file,
|
|
238
|
+
plan.exists(service)
|
|
239
|
+
? `import { defineController, route } from '@avijs/core';
|
|
240
|
+
import { ${serviceClass} } from '${serviceImport}';
|
|
241
|
+
|
|
242
|
+
export const ${exportName} = defineController({
|
|
243
|
+
prefix: '/${names.file}',
|
|
244
|
+
inject: [${serviceClass}],
|
|
245
|
+
routes: (${names.camel}) => [
|
|
246
|
+
route({ method: 'GET', path: '/', handler: () => ${names.camel}.findAll() }),
|
|
247
|
+
],
|
|
248
|
+
});
|
|
249
|
+
`
|
|
250
|
+
: `import { defineController, route } from '@avijs/core';
|
|
251
|
+
|
|
252
|
+
export const ${exportName} = defineController({
|
|
253
|
+
prefix: '/${names.file}',
|
|
254
|
+
routes: [route({ method: 'GET', path: '/', handler: () => [] })],
|
|
255
|
+
});
|
|
256
|
+
`,
|
|
257
|
+
);
|
|
258
|
+
await register(plan, app, 'controllers', exportName, file);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** The closest `*.module.ts` from `dir` up to `src/modules`, else `src/app.module.ts`. */
|
|
262
|
+
function nearestModule(plan: Plan, src: string, dir: string): string {
|
|
263
|
+
const stop = path.join(src, 'modules');
|
|
264
|
+
for (let current = dir; current.startsWith(stop); current = path.dirname(current)) {
|
|
265
|
+
const own = path.join(current, `${path.basename(current)}.module.ts`);
|
|
266
|
+
if (plan.exists(own)) return own;
|
|
267
|
+
if (existsSync(current)) {
|
|
268
|
+
const other = readdirSync(current)
|
|
269
|
+
.filter((entry) => entry.endsWith('.module.ts'))
|
|
270
|
+
.sort()[0];
|
|
271
|
+
if (other) return path.join(current, other);
|
|
272
|
+
}
|
|
273
|
+
if (current === stop) break;
|
|
274
|
+
}
|
|
275
|
+
return path.join(src, 'app.module.ts');
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Adds `identifier` to the `property` array of the first `@Module({...})`, `defineModule({...})`
|
|
280
|
+
* or `.register({...})` object in `file`, and imports it. Leaves a note when it can't.
|
|
281
|
+
*/
|
|
282
|
+
async function register(
|
|
283
|
+
plan: Plan,
|
|
284
|
+
file: string,
|
|
285
|
+
property: string,
|
|
286
|
+
identifier: string,
|
|
287
|
+
from: string,
|
|
288
|
+
): Promise<void> {
|
|
289
|
+
const where = path.basename(file);
|
|
290
|
+
const manual = `NOTE Add ${identifier} to ${property} in ${where} yourself (import it from '${importPath(file, from)}').`;
|
|
291
|
+
if (!plan.exists(file)) {
|
|
292
|
+
plan.notes.push(manual);
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
const text = await plan.read(file);
|
|
296
|
+
const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
|
|
297
|
+
const object = findRegistration(source);
|
|
298
|
+
if (!object) {
|
|
299
|
+
plan.notes.push(manual);
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const edits: { at: number; text: string }[] = [];
|
|
304
|
+
const existing = object.properties.find(
|
|
305
|
+
(prop): prop is ts.PropertyAssignment =>
|
|
306
|
+
ts.isPropertyAssignment(prop) && prop.name.getText(source) === property,
|
|
307
|
+
);
|
|
308
|
+
if (existing) {
|
|
309
|
+
const array = existing.initializer;
|
|
310
|
+
if (!ts.isArrayLiteralExpression(array)) {
|
|
311
|
+
plan.notes.push(manual);
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
if (array.elements.some((element) => element.getText(source) === identifier)) return;
|
|
315
|
+
const last = array.elements.at(-1);
|
|
316
|
+
edits.push(
|
|
317
|
+
last
|
|
318
|
+
? { at: last.getEnd(), text: `, ${identifier}` }
|
|
319
|
+
: { at: array.getStart(source) + 1, text: identifier },
|
|
320
|
+
);
|
|
321
|
+
} else {
|
|
322
|
+
const last = object.properties.at(-1);
|
|
323
|
+
edits.push(
|
|
324
|
+
last
|
|
325
|
+
? { at: last.getEnd(), text: `, ${property}: [${identifier}]` }
|
|
326
|
+
: { at: object.getStart(source) + 1, text: ` ${property}: [${identifier}] ` },
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const imported = source.statements.some(
|
|
331
|
+
(statement) =>
|
|
332
|
+
ts.isImportDeclaration(statement) &&
|
|
333
|
+
statement.importClause?.namedBindings !== undefined &&
|
|
334
|
+
ts.isNamedImports(statement.importClause.namedBindings) &&
|
|
335
|
+
statement.importClause.namedBindings.elements.some((e) => e.name.text === identifier),
|
|
336
|
+
);
|
|
337
|
+
if (!imported) {
|
|
338
|
+
const line = `import { ${identifier} } from '${importPath(file, from)}';`;
|
|
339
|
+
const lastImport = source.statements.filter(ts.isImportDeclaration).at(-1);
|
|
340
|
+
edits.push(
|
|
341
|
+
lastImport ? { at: lastImport.getEnd(), text: `\n${line}` } : { at: 0, text: `${line}\n` },
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
let updated = text;
|
|
346
|
+
for (const edit of edits.sort((a, b) => b.at - a.at)) {
|
|
347
|
+
updated = updated.slice(0, edit.at) + edit.text + updated.slice(edit.at);
|
|
348
|
+
}
|
|
349
|
+
plan.update(file, updated);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function findRegistration(source: ts.SourceFile): ts.ObjectLiteralExpression | undefined {
|
|
353
|
+
let found: ts.ObjectLiteralExpression | undefined;
|
|
354
|
+
const visit = (node: ts.Node): void => {
|
|
355
|
+
if (found) return;
|
|
356
|
+
if (ts.isCallExpression(node)) {
|
|
357
|
+
const callee = node.expression;
|
|
358
|
+
const name = ts.isIdentifier(callee)
|
|
359
|
+
? callee.text
|
|
360
|
+
: ts.isPropertyAccessExpression(callee)
|
|
361
|
+
? callee.name.text
|
|
362
|
+
: undefined;
|
|
363
|
+
const [first] = node.arguments;
|
|
364
|
+
if (
|
|
365
|
+
(name === 'Module' || name === 'defineModule' || name === 'register') &&
|
|
366
|
+
first &&
|
|
367
|
+
ts.isObjectLiteralExpression(first)
|
|
368
|
+
) {
|
|
369
|
+
found = first;
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
ts.forEachChild(node, visit);
|
|
374
|
+
};
|
|
375
|
+
visit(source);
|
|
376
|
+
return found;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function importPath(from: string, to: string): string {
|
|
380
|
+
const relative = path
|
|
381
|
+
.relative(path.dirname(from), to)
|
|
382
|
+
.replaceAll('\\', '/')
|
|
383
|
+
.replace(/\.ts$/, '.js');
|
|
384
|
+
return relative.startsWith('.') ? relative : `./${relative}`;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function toNames(file: string): Names {
|
|
388
|
+
const camel = file.replace(/-([a-z0-9])/g, (_, char: string) => char.toUpperCase());
|
|
389
|
+
return { file, camel, pascal: capitalize(camel) };
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function capitalize(value: string): string {
|
|
393
|
+
return value.charAt(0).toUpperCase() + value.slice(1);
|
|
394
|
+
}
|
package/src/index.ts
ADDED