@neohm/nh-cli 1.1.4 → 1.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/README.md +146 -12
- package/package.json +1 -1
- package/src/commands/gen.flag.js +353 -0
- package/src/commands/gen.js +36 -78
- package/src/index.js +57 -2
- package/src/lib/collision.js +82 -0
- package/src/lib/format.js +54 -0
- package/src/lib/registry.js +120 -0
- package/src/lib/sync.engine.js +34 -7
- package/src/lib/templates.js +349 -32
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
A scaffolding CLI that accelerates development on **neohm. NestJS projects**
|
|
4
4
|
built on top of the shared [`@neohm/nestend`](../nestend) library.
|
|
5
5
|
|
|
6
|
-
`nh` generates fully wired modules, entities, controllers, DTOs, data
|
|
6
|
+
`nh` generates fully wired modules, entities, controllers, services, DTOs, data
|
|
7
7
|
processors, migrations and seeds — and keeps your import barrels
|
|
8
8
|
(`es6.classes.ts`) and `app.module.ts` registration in sync, so the project
|
|
9
9
|
builds without any manual import wiring.
|
|
@@ -36,7 +36,8 @@ The project root is auto-detected by walking up from the current directory for a
|
|
|
36
36
|
|
|
37
37
|
| Command | Description |
|
|
38
38
|
| --------------------- | ---------------------------------------------------------------------------------------------------------------- |
|
|
39
|
-
| `nh gen` | Interactive wizard: scaffold a module + entity + optional controller/DTO/processor/migration, then auto-wire it. |
|
|
39
|
+
| `nh gen` | Interactive wizard: scaffold a module + entity + optional controller/service/DTO/processor/migration, then auto-wire it. |
|
|
40
|
+
| `nh gen <flag>` | Generate one artifact instead of a whole module — see [Flag generation](#flag-generation). |
|
|
40
41
|
| `nh migration` | Generate a standalone migration `<timestamp>M-<Name>.ts`. |
|
|
41
42
|
| `nh seed` | Generate a seed file `<timestamp>S-<Name>.ts`. |
|
|
42
43
|
| `nh sync` | Rebuild every `es6.classes.ts` barrel and register modules in `app.module.ts`. |
|
|
@@ -66,11 +67,14 @@ shape/
|
|
|
66
67
|
shape.module.ts # ShapeModule (imports CommonModule, QueueModule, ...)
|
|
67
68
|
es6.classes.ts # barrel of controllers/services/jobs/subscribers
|
|
68
69
|
controllers/shape.controller.ts
|
|
70
|
+
services/shape.service.ts
|
|
69
71
|
dtos/add.shape.data.dto.ts
|
|
70
72
|
entities/shape.entity.ts
|
|
71
73
|
libraries/shape.data.processor.ts
|
|
72
74
|
libraries/shape.list.processor.ts
|
|
73
|
-
enums/ jobs/
|
|
75
|
+
enums/ jobs/ subscribers/
|
|
76
|
+
external/ # optional API-key surface (nh gen -x ...)
|
|
77
|
+
controllers/ libraries/ services/
|
|
74
78
|
```
|
|
75
79
|
|
|
76
80
|
plus `src/database/migrations/<timestamp>M-AddShapeTable.ts`, and it
|
|
@@ -89,14 +93,143 @@ created spreading `baseJobConstants` / `baseEntityConstants` /
|
|
|
89
93
|
|
|
90
94
|
Generated artifacts extend the `@neohm/nestend` base classes:
|
|
91
95
|
|
|
92
|
-
- Entity → `CommonEntity` (with
|
|
96
|
+
- Entity → `CommonEntity` (a `tenant_id` column with its `TenantEntity`
|
|
97
|
+
relation, plus an empty `attributes` jsonb column)
|
|
93
98
|
- DTO → `CommonDataDto`
|
|
94
|
-
- Data processor → `CommonDataProcessor` (`process()` → `validate()` → `set()`)
|
|
99
|
+
- Data processor → `CommonDataProcessor` (`process()` → `validate()` → `set()`),
|
|
100
|
+
reached through the service's `upsert()`
|
|
95
101
|
- Listing processor → `CommonListProcessor` (column allowlist config, `id` default sort). It takes `CommonListFilterDto` directly — no per-list DTO is generated, since paging, sorting, filters and free-text search all live on the base DTO and the filterable columns are declared in the processor's `columns` config.
|
|
96
102
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
103
|
+
### Controller → service → processor
|
|
104
|
+
|
|
105
|
+
Controllers hold no business logic. They authenticate with
|
|
106
|
+
`AuthService.validateAccess()`, then delegate to the module's service — so the
|
|
107
|
+
same operation is reusable from a job, a subscriber or another service, and the
|
|
108
|
+
HTTP surface stays readable.
|
|
109
|
+
|
|
110
|
+
The service owns single-record reads and hands write and listing work to the
|
|
111
|
+
processors. It is **not** a prompt — it is always generated, prefilled from the
|
|
112
|
+
answers you already gave:
|
|
113
|
+
|
|
114
|
+
| Service method | Generated when | Controller endpoint |
|
|
115
|
+
| --- | --- | --- |
|
|
116
|
+
| `get(user, id)` | always | `GET /api/v1/shape/:id` |
|
|
117
|
+
| `upsert(data)` | data processor: Yes | `POST /api/v1/shape` |
|
|
118
|
+
| `list(user, data)` | listing processor: Yes | `POST /api/v1/shape/list` |
|
|
119
|
+
|
|
120
|
+
`get()` is the one operation that needs no processor, so a module that declines
|
|
121
|
+
both still gets a useful service with a single read method (and a controller
|
|
122
|
+
with just the `GET`). Declining a processor drops the matching service method
|
|
123
|
+
*and* its endpoint together, so the generated module always compiles.
|
|
124
|
+
|
|
125
|
+
`upsert()` rather than `create()` because `CommonDataDto` carries an optional
|
|
126
|
+
`id`: the same payload shape inserts when it is absent and updates when it is
|
|
127
|
+
present, and the data processor decides which. That is also why the endpoint is
|
|
128
|
+
a bare `@Post()` on the collection root rather than a `create` segment — one
|
|
129
|
+
route covers both halves.
|
|
130
|
+
|
|
131
|
+
`get()` scopes on the caller's tenant as part of the lookup —
|
|
132
|
+
`where: { id, tenant_id: user.organization_id }` — so a row belonging to
|
|
133
|
+
another tenant reads as *not found* rather than *forbidden*. (`organization_id`
|
|
134
|
+
is what the tenant is called on `UserEntity`; `tenant_id` is what it is called
|
|
135
|
+
on the row — the same value on two sides of one relation.) The listing
|
|
136
|
+
processor applies the same scope as a private predicate.
|
|
137
|
+
|
|
138
|
+
The generated scope and role identifiers are `'add-scope'` / `'add-role'`
|
|
139
|
+
placeholders, flagged with a `TODO` on the controller — replace them with the
|
|
140
|
+
`sys_scopes` identifier and role each endpoint actually sits behind.
|
|
141
|
+
|
|
142
|
+
## Flag generation
|
|
143
|
+
|
|
144
|
+
Bare `nh gen` scaffolds a whole module. Once that module exists, most work is
|
|
145
|
+
adding *one* more file to it — which is what the target flags do. Each asks
|
|
146
|
+
only what it cannot infer, refuses to shadow a name already in the project,
|
|
147
|
+
registers the result in `es6.classes.ts`, and formats what it wrote.
|
|
148
|
+
|
|
149
|
+
| Command | Generates | Asks |
|
|
150
|
+
| --- | --- | --- |
|
|
151
|
+
| `nh gen -c` | `controllers/<name>.controller.ts` | module, name, route path |
|
|
152
|
+
| `nh gen -s` | `services/<name>.service.ts` | module, name |
|
|
153
|
+
| `nh gen -dp` | `libraries/<name>.data.processor.ts` + `dtos/add.<name>.data.dto.ts` | module, name |
|
|
154
|
+
| `nh gen -lp` | `libraries/<name>.list.processor.ts` | module, name, table name |
|
|
155
|
+
| `nh gen -e` | `entities/<name>.entity.ts` + `dtos/<name>.attributes.dto.ts` + `jobs/<name>.job.ts` + `subscribers/<name>.subscriber.ts` + a migration | module, name, table name |
|
|
156
|
+
| `nh gen -j` | `jobs/<name>.job.ts` | module, name |
|
|
157
|
+
|
|
158
|
+
`-e` writes the same five files the wizard does, because an entity is not
|
|
159
|
+
useful alone: TypeORM drives its job through its subscriber, so an entity
|
|
160
|
+
without them is a table nothing reacts to. It registers in both sets of
|
|
161
|
+
registries as a result — `entity.constants.ts` / `entity.maps.ts` for the
|
|
162
|
+
entity, `job.constants.ts` / `job.maps.ts` for the job it brought along.
|
|
163
|
+
`-j` registers in the job pair only.
|
|
164
|
+
|
|
165
|
+
The two jobs are deliberately different shapes:
|
|
166
|
+
|
|
167
|
+
| | Driven by | `handle()` takes |
|
|
168
|
+
| --- | --- | --- |
|
|
169
|
+
| the job `-e` brings along | the entity's subscriber | `event: DatabaseEventDto<<Name>Entity>` |
|
|
170
|
+
| `nh gen -j` | an explicit `dispatch()` | `data: unknown` |
|
|
171
|
+
|
|
172
|
+
So reach for `-j` when you want a queue worker that is not tied to a table's
|
|
173
|
+
lifecycle.
|
|
174
|
+
|
|
175
|
+
Only one target flag per run; the module is created if it does not exist yet.
|
|
176
|
+
|
|
177
|
+
### `-x`: the external (API-key) surface
|
|
178
|
+
|
|
179
|
+
`-x` mirrors a target onto the module's API-key surface, under
|
|
180
|
+
`<module>/external/`:
|
|
181
|
+
|
|
182
|
+
| Command | Generates |
|
|
183
|
+
| --- | --- |
|
|
184
|
+
| `nh gen -x -c` | `external/controllers/external.<name>.controller.ts` |
|
|
185
|
+
| `nh gen -x -s` | `external/services/external.<name>.service.ts` |
|
|
186
|
+
| `nh gen -x -dp` | `external/libraries/external.<name>.data.processor.ts` |
|
|
187
|
+
| `nh gen -x -lp` | `external/libraries/external.<name>.list.processor.ts` |
|
|
188
|
+
|
|
189
|
+
External files take an `external.` filename prefix and an `External` class
|
|
190
|
+
prefix. An external controller differs from an internal one in three ways, all
|
|
191
|
+
load-bearing: it mounts at `api/e/v1/<path>`, it declares the key to swagger
|
|
192
|
+
with `@ApiSecurity('api-key')`, and it authenticates through `ApiKeyService`
|
|
193
|
+
rather than `AuthService`.
|
|
194
|
+
|
|
195
|
+
`-x` does not apply to `-e` or `-j`: a table and a queue worker have no
|
|
196
|
+
inward/outward face, so there is nothing to mirror. Both refuse the flag rather
|
|
197
|
+
than quietly ignoring it.
|
|
198
|
+
|
|
199
|
+
External providers register into the *same* `es6.classes.ts` categories as
|
|
200
|
+
their internal siblings, listed after them — so `nh sync` and `nh check` see
|
|
201
|
+
`<module>/external/<folder>` as well as `<module>/<folder>`.
|
|
202
|
+
|
|
203
|
+
### Name collisions
|
|
204
|
+
|
|
205
|
+
A flag generator checks the whole of `src/` for the name it is about to take —
|
|
206
|
+
both the exported class and the filename — and stops before writing if it is
|
|
207
|
+
taken. Every artifact is checked up front, so `-e` cannot write the entity and
|
|
208
|
+
then abort on the job:
|
|
209
|
+
|
|
210
|
+
```text
|
|
211
|
+
$ nh gen -j
|
|
212
|
+
? Module name (existing: billing, inventory, service): billing
|
|
213
|
+
? Job name (PascalCase): Cleanup
|
|
214
|
+
✗ a class named CleanupJob already exists in src/inventory/jobs/cleanup.job.ts.
|
|
215
|
+
Pick a different name, or edit that file instead — nothing was generated.
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
The check is project-wide, not module-local, because the registries these
|
|
219
|
+
feed are project-wide: two `CleanupJob` classes cannot both be
|
|
220
|
+
`JobConstants.cleanupJob`, and two identically named providers in one Nest
|
|
221
|
+
container is a bug that surfaces far from its cause. Dependency files are
|
|
222
|
+
exempt — the payload DTO for `-dp`, the attributes DTO for `-e`, and the
|
|
223
|
+
timestamped migration are reused when present and reported as `skip`.
|
|
224
|
+
|
|
225
|
+
## Formatting
|
|
226
|
+
|
|
227
|
+
The boilerplate lints with `prettier/prettier: error`, so `nh gen` runs the
|
|
228
|
+
**target project's** prettier (honouring its `.prettierrc`) over everything it
|
|
229
|
+
writes before finishing. Templates already aim to emit prettier-clean output;
|
|
230
|
+
this keeps that true for entity names long enough to push a line past 80
|
|
231
|
+
columns. A project without prettier installed still gets its files, just
|
|
232
|
+
unformatted.
|
|
100
233
|
|
|
101
234
|
- Migration → `MigrationUtility`
|
|
102
235
|
- Seed → `SeederUtility`
|
|
@@ -104,11 +237,12 @@ ships with a `POST list` endpoint that runs the listing processor (with
|
|
|
104
237
|
Re-running `gen` for an existing module reuses it and only adds new files —
|
|
105
238
|
existing files are never overwritten.
|
|
106
239
|
|
|
107
|
-
|
|
240
|
+
## `nh sync` and `nh check`
|
|
108
241
|
|
|
109
|
-
`sync` scans every module under `src/`
|
|
110
|
-
barrel from the files actually present in
|
|
111
|
-
|
|
242
|
+
`sync` scans every module under `src/` — including its `external/` mirror —
|
|
243
|
+
and regenerates its `es6.classes.ts` barrel from the files actually present in
|
|
244
|
+
`controllers/`, `services/`, `libraries/`, `dtos/`, `jobs/` and
|
|
245
|
+
`subscribers/`, then ensures each module is imported and listed in
|
|
112
246
|
`app.module.ts`. Files are classified by their **folder** and the presence of an
|
|
113
247
|
exported class — not by a fragile filename suffix — so non-standard names are
|
|
114
248
|
never silently dropped. The operation is idempotent.
|
package/package.json
CHANGED
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const inquirer = require('inquirer');
|
|
6
|
+
const chalk = require('chalk');
|
|
7
|
+
|
|
8
|
+
const { projectPaths, MODULE_SUBFOLDERS } = require('../lib/paths');
|
|
9
|
+
const naming = require('../lib/naming');
|
|
10
|
+
const tpl = require('../lib/templates');
|
|
11
|
+
const {
|
|
12
|
+
writeFile,
|
|
13
|
+
report: reportFile,
|
|
14
|
+
log,
|
|
15
|
+
ensureDir,
|
|
16
|
+
} = require('../lib/fs.utils');
|
|
17
|
+
const sync = require('../lib/sync.engine');
|
|
18
|
+
const { formatFiles } = require('../lib/format');
|
|
19
|
+
const { registerJob, registerEntity } = require('../lib/registry');
|
|
20
|
+
const { findConflict, describeConflict } = require('../lib/collision');
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Single-artifact generation: `nh gen -c`, `-s`, `-dp`, `-lp`, `-e`, `-j`, and
|
|
24
|
+
* the `-x` (external, API-key surface) variants of the first four.
|
|
25
|
+
*
|
|
26
|
+
* Bare `nh gen` still runs the full wizard — this path exists for adding one
|
|
27
|
+
* more file to a module that already exists, which is most of the work after
|
|
28
|
+
* the initial scaffold.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
// Short noun, used in the name prompt and error messages.
|
|
32
|
+
const LABELS = {
|
|
33
|
+
controller: 'controller',
|
|
34
|
+
service: 'service',
|
|
35
|
+
dataProcessor: 'data processor',
|
|
36
|
+
listProcessor: 'list processor',
|
|
37
|
+
entity: 'entity',
|
|
38
|
+
job: 'job',
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// What the target actually writes, for the header line.
|
|
42
|
+
const DESCRIBES = {
|
|
43
|
+
controller: 'controller',
|
|
44
|
+
service: 'service',
|
|
45
|
+
dataProcessor: 'data processor (+ its payload DTO)',
|
|
46
|
+
listProcessor: 'list processor',
|
|
47
|
+
entity: 'entity, its job and subscriber, and a migration',
|
|
48
|
+
job: 'job',
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// `-x` mirrors a target under <module>/external/. Entities are tables and jobs
|
|
52
|
+
// are queue workers — neither has an inward/outward face, so neither takes -x.
|
|
53
|
+
const EXTERNAL_CAPABLE = new Set([
|
|
54
|
+
'controller',
|
|
55
|
+
'service',
|
|
56
|
+
'dataProcessor',
|
|
57
|
+
'listProcessor',
|
|
58
|
+
]);
|
|
59
|
+
|
|
60
|
+
const FLAG = {
|
|
61
|
+
controller: '-c',
|
|
62
|
+
service: '-s',
|
|
63
|
+
dataProcessor: '-dp',
|
|
64
|
+
listProcessor: '-lp',
|
|
65
|
+
entity: '-e',
|
|
66
|
+
job: '-j',
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The files a target owns, as {className, file} pairs.
|
|
71
|
+
*
|
|
72
|
+
* "Owns" excludes anything reused rather than claimed: the payload DTO for
|
|
73
|
+
* -dp, the attributes DTO for -e, and the timestamped migration. Those are
|
|
74
|
+
* shared or unique-by-construction, so they are created when absent and left
|
|
75
|
+
* alone when present — only the artifacts listed here are name-checked.
|
|
76
|
+
*
|
|
77
|
+
* An entity owns three files, not one: TypeORM drives the job through the
|
|
78
|
+
* subscriber, so an entity without them is a table nothing reacts to. The
|
|
79
|
+
* wizard has always written all three together; -e does the same.
|
|
80
|
+
*/
|
|
81
|
+
function primaryArtifacts(target, moduleDir, n, external) {
|
|
82
|
+
const at = (...parts) =>
|
|
83
|
+
external
|
|
84
|
+
? path.join(moduleDir, 'external', ...parts)
|
|
85
|
+
: path.join(moduleDir, ...parts);
|
|
86
|
+
const one = (className, ...parts) => [{ className, file: at(...parts) }];
|
|
87
|
+
|
|
88
|
+
switch (target) {
|
|
89
|
+
case 'controller':
|
|
90
|
+
return external
|
|
91
|
+
? one(
|
|
92
|
+
n.externalControllerClass,
|
|
93
|
+
'controllers',
|
|
94
|
+
n.externalControllerFile,
|
|
95
|
+
)
|
|
96
|
+
: one(n.controllerClass, 'controllers', n.controllerFile);
|
|
97
|
+
case 'service':
|
|
98
|
+
return external
|
|
99
|
+
? one(n.externalServiceClass, 'services', n.externalServiceFile)
|
|
100
|
+
: one(n.serviceClass, 'services', n.serviceFile);
|
|
101
|
+
case 'dataProcessor':
|
|
102
|
+
return external
|
|
103
|
+
? one(n.externalProcessorClass, 'libraries', n.externalProcessorFile)
|
|
104
|
+
: one(n.processorClass, 'libraries', n.processorFile);
|
|
105
|
+
case 'listProcessor':
|
|
106
|
+
return external
|
|
107
|
+
? one(
|
|
108
|
+
n.externalListProcessorClass,
|
|
109
|
+
'libraries',
|
|
110
|
+
n.externalListProcessorFile,
|
|
111
|
+
)
|
|
112
|
+
: one(n.listProcessorClass, 'libraries', n.listProcessorFile);
|
|
113
|
+
case 'entity':
|
|
114
|
+
return [
|
|
115
|
+
{
|
|
116
|
+
className: n.entityClass,
|
|
117
|
+
file: path.join(moduleDir, 'entities', n.entityFile),
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
className: n.jobClass,
|
|
121
|
+
file: path.join(moduleDir, 'jobs', n.jobFile),
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
className: n.subscriberClass,
|
|
125
|
+
file: path.join(moduleDir, 'subscribers', n.subscriberFile),
|
|
126
|
+
},
|
|
127
|
+
];
|
|
128
|
+
case 'job':
|
|
129
|
+
return [
|
|
130
|
+
{
|
|
131
|
+
className: n.jobClass,
|
|
132
|
+
file: path.join(moduleDir, 'jobs', n.jobFile),
|
|
133
|
+
},
|
|
134
|
+
];
|
|
135
|
+
default:
|
|
136
|
+
throw new Error(`Unknown target "${target}".`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Create the module skeleton if this is the first file going into it. */
|
|
141
|
+
function ensureModule(paths, moduleDot, external, report) {
|
|
142
|
+
const moduleDir = paths.moduleDir(moduleDot);
|
|
143
|
+
const existed = fs.existsSync(moduleDir);
|
|
144
|
+
|
|
145
|
+
for (const sub of MODULE_SUBFOLDERS) ensureDir(path.join(moduleDir, sub));
|
|
146
|
+
if (external) {
|
|
147
|
+
for (const sub of ['controllers', 'libraries', 'services']) {
|
|
148
|
+
ensureDir(path.join(moduleDir, 'external', sub));
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const modulePath = path.join(moduleDir, `${moduleDot}.module.ts`);
|
|
153
|
+
const es6Path = path.join(moduleDir, 'es6.classes.ts');
|
|
154
|
+
const a = writeFile(modulePath, tpl.moduleFile(moduleDot));
|
|
155
|
+
const b = writeFile(es6Path, tpl.emptyEs6Classes());
|
|
156
|
+
if (a !== 'skipped') report(a, modulePath, paths.root);
|
|
157
|
+
if (b !== 'skipped') report(b, es6Path, paths.root);
|
|
158
|
+
|
|
159
|
+
if (!existed) log.info(`Created module "${moduleDot}".`);
|
|
160
|
+
return moduleDir;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function genFlag(target, { external = false } = {}) {
|
|
164
|
+
if (!LABELS[target]) throw new Error(`Unknown target "${target}".`);
|
|
165
|
+
if (external && !EXTERNAL_CAPABLE.has(target)) {
|
|
166
|
+
throw new Error(
|
|
167
|
+
`-x is not supported for ${LABELS[target]} — external applies to ` +
|
|
168
|
+
`-c, -s, -dp and -lp only. Run \`nh gen ${FLAG[target]}\` without -x.`,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const paths = projectPaths();
|
|
173
|
+
log.info(`Project: ${paths.root}\n`);
|
|
174
|
+
|
|
175
|
+
const touched = [];
|
|
176
|
+
const report = (result, filePath, root) => {
|
|
177
|
+
if (result !== 'skipped') touched.push(filePath);
|
|
178
|
+
reportFile(result, filePath, root);
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
log.info(`Generating: ${external ? 'external ' : ''}${DESCRIBES[target]}.`);
|
|
182
|
+
|
|
183
|
+
// 1. Module ---------------------------------------------------------------
|
|
184
|
+
const known = sync.scanModules(paths).map((m) => m.moduleDot);
|
|
185
|
+
const { moduleNameRaw } = await inquirer.prompt([
|
|
186
|
+
{
|
|
187
|
+
type: 'input',
|
|
188
|
+
name: 'moduleNameRaw',
|
|
189
|
+
message: known.length
|
|
190
|
+
? `Module name (existing: ${known.join(', ')}):`
|
|
191
|
+
: 'Module name:',
|
|
192
|
+
validate: (v) => (v && v.trim() ? true : 'Module name is required'),
|
|
193
|
+
},
|
|
194
|
+
]);
|
|
195
|
+
const moduleDot = naming.toDotCase(moduleNameRaw);
|
|
196
|
+
|
|
197
|
+
// 2. Name -----------------------------------------------------------------
|
|
198
|
+
const { rawName } = await inquirer.prompt([
|
|
199
|
+
{
|
|
200
|
+
type: 'input',
|
|
201
|
+
name: 'rawName',
|
|
202
|
+
message: `${LABELS[target].replace(/^./, (c) => c.toUpperCase())} name (PascalCase):`,
|
|
203
|
+
validate: (v) => (v && v.trim() ? true : 'Name is required'),
|
|
204
|
+
},
|
|
205
|
+
]);
|
|
206
|
+
const n = tpl.names(rawName);
|
|
207
|
+
|
|
208
|
+
// 3. Target-specific answers ---------------------------------------------
|
|
209
|
+
let routePath;
|
|
210
|
+
if (target === 'controller') {
|
|
211
|
+
({ routePath } = await inquirer.prompt([
|
|
212
|
+
{
|
|
213
|
+
type: 'input',
|
|
214
|
+
name: 'routePath',
|
|
215
|
+
message: external
|
|
216
|
+
? 'Route path (mounted at api/e/v1/<path>):'
|
|
217
|
+
: 'Route path (mounted at api/v1/<path>):',
|
|
218
|
+
default: n.kebab,
|
|
219
|
+
validate: (v) => (v && v.trim() ? true : 'Route path is required'),
|
|
220
|
+
},
|
|
221
|
+
]));
|
|
222
|
+
routePath = routePath.trim().replace(/^\/+|\/+$/g, '');
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
let tableName;
|
|
226
|
+
if (target === 'listProcessor' || target === 'entity') {
|
|
227
|
+
({ tableName } = await inquirer.prompt([
|
|
228
|
+
{
|
|
229
|
+
type: 'input',
|
|
230
|
+
name: 'tableName',
|
|
231
|
+
message:
|
|
232
|
+
target === 'entity' ? 'Table name:' : 'Table to list (table name):',
|
|
233
|
+
default: `nh_${naming.toWords(rawName).join('_')}s`,
|
|
234
|
+
validate: (v) => (v && v.trim() ? true : 'Table name is required'),
|
|
235
|
+
},
|
|
236
|
+
]));
|
|
237
|
+
tableName = tableName.trim();
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// 4. Refuse to shadow a name that is already taken ------------------------
|
|
241
|
+
// Checked for every artifact up front, so a target that writes three files
|
|
242
|
+
// does not create the first and then abort on the second.
|
|
243
|
+
const artifacts = primaryArtifacts(
|
|
244
|
+
target,
|
|
245
|
+
paths.moduleDir(moduleDot),
|
|
246
|
+
n,
|
|
247
|
+
external,
|
|
248
|
+
);
|
|
249
|
+
for (const artifact of artifacts) {
|
|
250
|
+
const conflict = findConflict(paths.srcDir, {
|
|
251
|
+
className: artifact.className,
|
|
252
|
+
fileName: path.basename(artifact.file),
|
|
253
|
+
});
|
|
254
|
+
if (conflict) {
|
|
255
|
+
throw new Error(
|
|
256
|
+
`${describeConflict(conflict, paths.root)}.\n` +
|
|
257
|
+
` Pick a different name, or edit that file instead — ` +
|
|
258
|
+
`nothing was generated.`,
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
const dest = artifacts[0].file;
|
|
263
|
+
|
|
264
|
+
// 5. Write ----------------------------------------------------------------
|
|
265
|
+
const moduleDir = ensureModule(paths, moduleDot, external, report);
|
|
266
|
+
const write = (filePath, content) =>
|
|
267
|
+
report(writeFile(filePath, content), filePath, paths.root);
|
|
268
|
+
|
|
269
|
+
switch (target) {
|
|
270
|
+
case 'controller':
|
|
271
|
+
write(
|
|
272
|
+
dest,
|
|
273
|
+
external
|
|
274
|
+
? tpl.bareExternalController(rawName, routePath)
|
|
275
|
+
: tpl.bareController(rawName, routePath),
|
|
276
|
+
);
|
|
277
|
+
break;
|
|
278
|
+
|
|
279
|
+
case 'service':
|
|
280
|
+
write(dest, tpl.bareService(rawName, { external }));
|
|
281
|
+
break;
|
|
282
|
+
|
|
283
|
+
case 'dataProcessor':
|
|
284
|
+
write(dest, tpl.processor(rawName, { external }));
|
|
285
|
+
// The processor's payload DTO lives in the module's dtos/ either way —
|
|
286
|
+
// created here only if it is not already there.
|
|
287
|
+
write(path.join(moduleDir, 'dtos', n.dtoFile), tpl.dto(rawName));
|
|
288
|
+
break;
|
|
289
|
+
|
|
290
|
+
case 'listProcessor':
|
|
291
|
+
write(dest, tpl.listProcessor(rawName, tableName, { external }));
|
|
292
|
+
break;
|
|
293
|
+
|
|
294
|
+
case 'entity': {
|
|
295
|
+
write(
|
|
296
|
+
path.join(moduleDir, 'dtos', n.attributesDtoFile),
|
|
297
|
+
tpl.attributesDto(rawName),
|
|
298
|
+
);
|
|
299
|
+
write(dest, tpl.entity(rawName, tableName));
|
|
300
|
+
|
|
301
|
+
// The entity's lifecycle hook and the job it drives. Same pair the
|
|
302
|
+
// wizard writes: the subscriber listens to the entity and hands each
|
|
303
|
+
// event to the job, which is why the job takes a DatabaseEventDto here
|
|
304
|
+
// rather than the loose payload `nh gen -j` scaffolds.
|
|
305
|
+
write(path.join(moduleDir, 'jobs', n.jobFile), tpl.job(rawName));
|
|
306
|
+
write(
|
|
307
|
+
path.join(moduleDir, 'subscribers', n.subscriberFile),
|
|
308
|
+
tpl.subscriber(rawName),
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
const ts = Date.now();
|
|
312
|
+
const migrationPath = path.join(
|
|
313
|
+
paths.migrationsDir,
|
|
314
|
+
naming.migrationFileName(n.migrationName, ts),
|
|
315
|
+
);
|
|
316
|
+
write(migrationPath, tpl.genMigration(rawName, tableName, ts));
|
|
317
|
+
|
|
318
|
+
registerEntity(paths, rawName, moduleDot, report);
|
|
319
|
+
registerJob(paths, rawName, moduleDot, report);
|
|
320
|
+
break;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
case 'job':
|
|
324
|
+
write(dest, tpl.additionalJob(rawName));
|
|
325
|
+
registerJob(paths, rawName, moduleDot, report);
|
|
326
|
+
break;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// 6. Wire it into the barrel + app.module, then format -------------------
|
|
330
|
+
log.info('\nWiring imports...');
|
|
331
|
+
const mod = sync.scanModule(
|
|
332
|
+
moduleDir,
|
|
333
|
+
path.join(moduleDir, `${moduleDot}.module.ts`),
|
|
334
|
+
);
|
|
335
|
+
const written = sync.applyPlan(sync.buildPlan(paths, [mod]));
|
|
336
|
+
for (const w of written) report('overwritten', w, paths.root);
|
|
337
|
+
if (!written.length) log.info(' (already in sync)');
|
|
338
|
+
|
|
339
|
+
const formatted = await formatFiles(touched, paths.root);
|
|
340
|
+
if (formatted === null) {
|
|
341
|
+
log.info(' (prettier not installed — files left unformatted)');
|
|
342
|
+
} else if (formatted) {
|
|
343
|
+
log.info(` formatted ${formatted} file(s)`);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const made = artifacts.map((a) => a.className).join(', ');
|
|
347
|
+
log.done(`${made} generated in "${moduleDot}".`);
|
|
348
|
+
console.log(
|
|
349
|
+
chalk.dim('Run `nh sync` anytime to re-wire imports after manual edits.'),
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
module.exports = genFlag;
|