@avijs/cli 0.1.1 → 0.2.0

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/src/templates.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { SYNTAX, VALIDATOR_VERSIONS, type SchemaSyntax, type Validator } from './validators.js';
2
+
1
3
  export type TemplateName = 'modular' | 'minimal';
2
4
 
3
5
  export interface TemplateOptions {
@@ -11,22 +13,29 @@ export interface TemplateOptions {
11
13
  * each other resolve locally too.
12
14
  */
13
15
  localPackages?: Readonly<Record<string, string>>;
16
+ /** How the users routes validate input. Default: `valibot`. */
17
+ validator?: Validator;
14
18
  }
15
19
 
16
20
  /** Every file of a new project, keyed by its path relative to the project folder. */
17
21
  export function templateFiles(options: TemplateOptions): Record<string, string> {
18
- return {
19
- ...shared(options),
20
- ...(options.template === 'modular' ? modular() : minimal()),
21
- };
22
+ const validator = options.validator ?? 'valibot';
23
+ const syntax = validator === 'none' ? undefined : SYNTAX[validator];
24
+ const files = options.template === 'modular' ? modular() : minimal();
25
+ if (syntax) {
26
+ Object.assign(
27
+ files,
28
+ options.template === 'modular' ? modularSchemas(syntax) : minimalSchemas(syntax),
29
+ );
30
+ if (options.template === 'modular') delete files['src/modules/users/dto/create-user.dto.ts'];
31
+ }
32
+ return { ...shared(options, validator), ...files };
22
33
  }
23
34
 
24
- function shared({
25
- name,
26
- template,
27
- aviVersion,
28
- localPackages,
29
- }: TemplateOptions): Record<string, string> {
35
+ function shared(
36
+ { name, template, aviVersion, localPackages }: TemplateOptions,
37
+ validator: Validator,
38
+ ): Record<string, string> {
30
39
  const spec = (pkg: string) => localPackages?.[pkg] ?? aviVersion;
31
40
  const pkg = {
32
41
  name,
@@ -40,7 +49,10 @@ function shared({
40
49
  check: 'avi check',
41
50
  test: 'tsc && node --test "dist/**/*.test.js"',
42
51
  },
43
- dependencies: { '@avijs/core': spec('@avijs/core') },
52
+ dependencies: {
53
+ '@avijs/core': spec('@avijs/core'),
54
+ ...(validator === 'none' ? {} : { [validator]: VALIDATOR_VERSIONS[validator] }),
55
+ },
44
56
  devDependencies: {
45
57
  '@avijs/cli': spec('@avijs/cli'),
46
58
  '@avijs/testing': spec('@avijs/testing'),
@@ -68,7 +80,7 @@ function shared({
68
80
  return {
69
81
  'package.json': `${JSON.stringify(pkg, null, 2)}\n`,
70
82
  'tsconfig.json': `${JSON.stringify(tsconfig, null, 2)}\n`,
71
- '.gitignore': 'node_modules/\ndist/\n',
83
+ '.gitignore': 'node_modules/\ndist/\n.env\n',
72
84
  'README.md': `# ${name}
73
85
 
74
86
  An [Avi.js](https://github.com/chrischase011/avi-js) API (${template} template).
@@ -85,7 +97,10 @@ Then open http://localhost:3000/health and http://localhost:3000/users.
85
97
  };
86
98
  }
87
99
 
88
- const MAIN_LISTEN = `const { port } = await app.listen(Number(process.env['PORT'] ?? 3000));
100
+ const MAIN_LISTEN = listenOn("Number(process.env['PORT'] ?? 3000)");
101
+
102
+ function listenOn(port: string): string {
103
+ return `const { port } = await app.listen(${port});
89
104
  console.log(\`Listening on http://localhost:\${String(port)}\`);
90
105
 
91
106
  const shutdown = () => {
@@ -94,6 +109,19 @@ const shutdown = () => {
94
109
  process.once('SIGINT', shutdown);
95
110
  process.once('SIGTERM', shutdown);
96
111
  `;
112
+ }
113
+
114
+ /** `src/config.ts`: environment variables checked at startup. */
115
+ function configFile(syntax: SchemaSyntax): string {
116
+ return `import { defineConfig } from '@avijs/core';
117
+ ${syntax.import}
118
+
119
+ // Environment variables, checked when the app starts. A .env file is read too, if present.
120
+ const env = ${syntax.object([['PORT', syntax.port]])};
121
+
122
+ export const Config = defineConfig(env, { envFile: '.env' });
123
+ `;
124
+ }
97
125
 
98
126
  function modular(): Record<string, string> {
99
127
  return {
@@ -331,3 +359,233 @@ test('answers 404 for an unknown user', async () => {
331
359
  `,
332
360
  };
333
361
  }
362
+
363
+ const REJECTS_NAMELESS = `test('rejects a user without a name', async () => {
364
+ const res = await client.post('/users', {});
365
+ assert.equal(res.status, 400);
366
+ const body = res.body as { details: { issues: { in: string; path: string[] }[] } };
367
+ assert.deepEqual(
368
+ body.details.issues.map((issue) => \`\${issue.in}.\${issue.path.join('.')}\`),
369
+ ['body.name'],
370
+ );
371
+ });
372
+ `;
373
+
374
+ /** The users feature with route schemas instead of a handwritten parser. */
375
+ function modularSchemas(syntax: SchemaSyntax): Record<string, string> {
376
+ return {
377
+ 'src/config.ts': configFile(syntax),
378
+ 'src/main.ts': `import { AviFactory } from '@avijs/core';
379
+ import { AppModule } from './app.module.js';
380
+ import { Config } from './config.js';
381
+
382
+ const app = await AviFactory.create(AppModule);
383
+ ${listenOn('app.get(Config).PORT')}`,
384
+ 'src/app.module.ts': `import { Module } from '@avijs/core';
385
+ import { HealthController } from './common/health.controller.js';
386
+ import { Config } from './config.js';
387
+ import { UsersModule } from './modules/users/users.module.js';
388
+
389
+ @Module({ imports: [Config.module, UsersModule], controllers: [HealthController] })
390
+ export class AppModule {}
391
+ `,
392
+ 'src/modules/users/dto/user.dto.ts': `${syntax.import}
393
+
394
+ export const UserId = ${syntax.id};
395
+
396
+ export const CreateUserDto = ${syntax.object([['name', syntax.text(100, 'name is required')]])};
397
+ export type CreateUserDto = ${syntax.infer('CreateUserDto')};
398
+
399
+ // Responses are checked against these and lose any field not listed, so internals cannot leak.
400
+ export const User = ${syntax.object([
401
+ ['id', syntax.number],
402
+ ['name', syntax.string],
403
+ ])};
404
+ export type User = ${syntax.infer('User')};
405
+
406
+ export const UserList = ${syntax.array('User')};
407
+ `,
408
+ 'src/modules/users/users.service.ts': `import { Injectable } from '@avijs/core';
409
+ import type { CreateUserDto, User } from './dto/user.dto.js';
410
+
411
+ @Injectable()
412
+ export class UsersService {
413
+ readonly #users: User[] = [{ id: 1, name: 'Ada' }];
414
+
415
+ findAll(): User[] {
416
+ return this.#users;
417
+ }
418
+
419
+ findOne(id: number): User | undefined {
420
+ return this.#users.find((user) => user.id === id);
421
+ }
422
+
423
+ create(dto: CreateUserDto): User {
424
+ const user = { id: this.#users.length + 1, name: dto.name };
425
+ this.#users.push(user);
426
+ return user;
427
+ }
428
+ }
429
+ `,
430
+ 'src/modules/users/users.controller.ts': `import {
431
+ Controller,
432
+ Get,
433
+ HttpError,
434
+ Post,
435
+ reply,
436
+ type RouteContext,
437
+ type RouteSchema,
438
+ } from '@avijs/core';
439
+ import { CreateUserDto, User, UserId, UserList } from './dto/user.dto.js';
440
+ import { UsersService } from './users.service.js';
441
+
442
+ const findOne = { params: UserId, response: User } satisfies RouteSchema;
443
+ const create = { body: CreateUserDto, response: User } satisfies RouteSchema;
444
+
445
+ @Controller('/users')
446
+ export class UsersController {
447
+ static inject = [UsersService] as const;
448
+
449
+ constructor(private readonly users: UsersService) {}
450
+
451
+ @Get('/', { schema: { response: UserList } })
452
+ findAll() {
453
+ return this.users.findAll();
454
+ }
455
+
456
+ @Get('/:id', { schema: findOne })
457
+ findOne(ctx: RouteContext<typeof findOne>) {
458
+ const user = this.users.findOne(ctx.input.params.id);
459
+ if (!user) throw new HttpError(404, 'User not found');
460
+ return user;
461
+ }
462
+
463
+ @Post('/', { schema: create })
464
+ create(ctx: RouteContext<typeof create>) {
465
+ return reply(this.users.create(ctx.input.body), { status: 201 });
466
+ }
467
+ }
468
+ `,
469
+ 'src/modules/users/users.test.ts': `import assert from 'node:assert/strict';
470
+ import { after, before, test } from 'node:test';
471
+ import { createTestClient, type TestClient } from '@avijs/testing';
472
+ import { AppModule } from '../../app.module.js';
473
+
474
+ let client: TestClient;
475
+ before(async () => {
476
+ client = await createTestClient(AppModule);
477
+ });
478
+ after(() => client.close());
479
+
480
+ test('lists users', async () => {
481
+ const res = await client.get('/users');
482
+ assert.equal(res.status, 200);
483
+ assert.deepEqual(res.body, [{ id: 1, name: 'Ada' }]);
484
+ });
485
+
486
+ test('creates a user', async () => {
487
+ const res = await client.post('/users', { name: ' Grace ', role: 'admin' });
488
+ assert.equal(res.status, 201);
489
+ assert.deepEqual(res.body, { id: 2, name: 'Grace' });
490
+ });
491
+
492
+ ${REJECTS_NAMELESS}`,
493
+ };
494
+ }
495
+
496
+ function minimalSchemas(syntax: SchemaSyntax): Record<string, string> {
497
+ return {
498
+ 'src/config.ts': configFile(syntax),
499
+ 'src/main.ts': `import { createApp } from './app.js';
500
+ import { Config } from './config.js';
501
+
502
+ const app = createApp();
503
+ await app.init();
504
+ ${listenOn('app.get(Config).PORT')}`,
505
+ 'src/app.ts': `import { createAvi } from '@avijs/core';
506
+ import { Config } from './config.js';
507
+ import { healthRoutes } from './routes/health.js';
508
+ import { usersRoutes } from './routes/users.js';
509
+ import { UsersService } from './services/users.js';
510
+
511
+ export function createApp() {
512
+ return createAvi().register({
513
+ imports: [Config.module],
514
+ providers: [UsersService],
515
+ controllers: [healthRoutes, usersRoutes],
516
+ });
517
+ }
518
+ `,
519
+ 'src/routes/users.ts': `import { HttpError, defineController, reply, route } from '@avijs/core';
520
+ ${syntax.import}
521
+ import { UsersService } from '../services/users.js';
522
+
523
+ const UserId = ${syntax.id};
524
+
525
+ const CreateUser = ${syntax.object([['name', syntax.text(100, 'name is required')]])};
526
+
527
+ // Responses are checked against this and lose any field not listed, so internals cannot leak.
528
+ const User = ${syntax.object([
529
+ ['id', syntax.number],
530
+ ['name', syntax.string],
531
+ ])};
532
+
533
+ export const usersRoutes = defineController({
534
+ prefix: '/users',
535
+ inject: [UsersService],
536
+ routes: (users) => [
537
+ route({
538
+ method: 'GET',
539
+ path: '/',
540
+ schema: { response: ${syntax.array('User')} },
541
+ handler: () => users.findAll(),
542
+ }),
543
+ route({
544
+ method: 'GET',
545
+ path: '/:id',
546
+ schema: { params: UserId, response: User },
547
+ handler: (ctx) => {
548
+ const user = users.findOne(ctx.input.params.id);
549
+ if (!user) throw new HttpError(404, 'User not found');
550
+ return user;
551
+ },
552
+ }),
553
+ route({
554
+ method: 'POST',
555
+ path: '/',
556
+ schema: { body: CreateUser, response: User },
557
+ handler: (ctx) => reply(users.create(ctx.input.body.name), { status: 201 }),
558
+ }),
559
+ ],
560
+ });
561
+ `,
562
+ 'src/app.test.ts': `import assert from 'node:assert/strict';
563
+ import { after, before, test } from 'node:test';
564
+ import { createTestClient, type TestClient } from '@avijs/testing';
565
+ import { createApp } from './app.js';
566
+
567
+ let client: TestClient;
568
+ before(async () => {
569
+ client = await createTestClient(createApp());
570
+ });
571
+ after(() => client.close());
572
+
573
+ test('lists users', async () => {
574
+ const res = await client.get('/users');
575
+ assert.equal(res.status, 200);
576
+ assert.deepEqual(res.body, [{ id: 1, name: 'Ada' }]);
577
+ });
578
+
579
+ test('creates a user', async () => {
580
+ const res = await client.post('/users', { name: ' Grace ', role: 'admin' });
581
+ assert.equal(res.status, 201);
582
+ assert.deepEqual(res.body, { id: 2, name: 'Grace' });
583
+ });
584
+
585
+ test('answers 404 for an unknown user', async () => {
586
+ assert.equal((await client.get('/users/99')).status, 404);
587
+ });
588
+
589
+ ${REJECTS_NAMELESS}`,
590
+ };
591
+ }
@@ -0,0 +1,85 @@
1
+ /** Validation libraries generated code can use. `none` keeps handwritten checks. */
2
+ export type Validator = 'valibot' | 'zod' | 'none';
3
+
4
+ export const VALIDATORS: readonly Validator[] = ['valibot', 'zod', 'none'];
5
+
6
+ /** Version ranges added to a new project's dependencies. */
7
+ export const VALIDATOR_VERSIONS: Record<Exclude<Validator, 'none'>, string> = {
8
+ valibot: '^1.5.0',
9
+ zod: '^4.6.0',
10
+ };
11
+
12
+ /** How each library spells the few schemas the templates and generators need. */
13
+ export interface SchemaSyntax {
14
+ readonly import: string;
15
+ /** Params `{ id }`: digits in, a number out. */
16
+ readonly id: string;
17
+ readonly number: string;
18
+ readonly string: string;
19
+ /** An optional `PORT` variable: digits, as a number, default 3000. */
20
+ readonly port: string;
21
+ /** A trimmed, required string of at most `max` characters. */
22
+ text(max: number, required: string): string;
23
+ object(fields: readonly (readonly [string, string])[]): string;
24
+ array(item: string): string;
25
+ /** Every field of `schema` optional, but at least one present. */
26
+ partialNonEmpty(schema: string): string;
27
+ /** The output type of `schema`. */
28
+ infer(schema: string): string;
29
+ }
30
+
31
+ const fields = (entries: readonly (readonly [string, string])[]) =>
32
+ entries.map(([key, value]) => ` ${key}: ${value},\n`).join('');
33
+
34
+ export const SYNTAX: Record<Exclude<Validator, 'none'>, SchemaSyntax> = {
35
+ valibot: {
36
+ import: "import * as v from 'valibot';",
37
+ id: `v.object({
38
+ id: v.pipe(v.string(), v.regex(/^\\d+$/, 'id must be a number'), v.transform(Number)),
39
+ })`,
40
+ number: 'v.number()',
41
+ string: 'v.string()',
42
+ port: `v.optional(
43
+ v.pipe(v.string(), v.regex(/^\\d+$/, 'PORT must be a number'), v.transform(Number)),
44
+ '3000',
45
+ )`,
46
+ text: (max, required) =>
47
+ `v.pipe(v.string(), v.trim(), v.minLength(1, '${required}'), v.maxLength(${String(max)}))`,
48
+ object: (entries) => `v.object({\n${fields(entries)}})`,
49
+ array: (item) => `v.array(${item})`,
50
+ partialNonEmpty: (schema) => `v.pipe(
51
+ v.partial(${schema}),
52
+ v.check((input) => Object.keys(input).length > 0, 'nothing to update'),
53
+ )`,
54
+ infer: (schema) => `v.InferOutput<typeof ${schema}>`,
55
+ },
56
+ zod: {
57
+ import: "import { z } from 'zod';",
58
+ id: `z.object({
59
+ id: z.string().regex(/^\\d+$/, 'id must be a number').transform(Number),
60
+ })`,
61
+ number: 'z.number()',
62
+ string: 'z.string()',
63
+ port: "z.string().regex(/^\\d+$/, 'PORT must be a number').default('3000').transform(Number)",
64
+ text: (max, required) => `z.string().trim().min(1, '${required}').max(${String(max)})`,
65
+ object: (entries) => `z.object({\n${fields(entries)}})`,
66
+ array: (item) => `z.array(${item})`,
67
+ partialNonEmpty: (schema) => `${schema}
68
+ .partial()
69
+ .refine((input) => Object.keys(input).length > 0, 'nothing to update')`,
70
+ infer: (schema) => `z.infer<typeof ${schema}>`,
71
+ },
72
+ };
73
+
74
+ /** The validator a project already uses, from its package.json; Valibot wins over Zod. */
75
+ export function detectValidator(packageJson: unknown): Validator {
76
+ const pkg = packageJson as {
77
+ dependencies?: Record<string, unknown>;
78
+ devDependencies?: Record<string, unknown>;
79
+ } | null;
80
+ const has = (name: string) =>
81
+ pkg?.dependencies?.[name] !== undefined || pkg?.devDependencies?.[name] !== undefined;
82
+ if (has('valibot')) return 'valibot';
83
+ if (has('zod')) return 'zod';
84
+ return 'none';
85
+ }