@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 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/ services/ subscribers/
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 an empty `attributes` jsonb column)
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
- When both a controller and a listing processor are generated, the controller
98
- ships with a `POST list` endpoint that runs the listing processor (with
99
- `SqlService` injected via the constructor).
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
- ### `nh sync` and `nh check`
240
+ ## `nh sync` and `nh check`
108
241
 
109
- `sync` scans every module under `src/` and regenerates its `es6.classes.ts`
110
- barrel from the files actually present in `controllers/`, `services/`, `jobs/`
111
- and `subscribers/`, then ensures each module is imported and listed in
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@neohm/nh-cli",
3
- "version": "1.1.4",
3
+ "version": "1.2.0",
4
4
  "description": "nh — Command Line Interface for neohm. NestJS projects",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -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;