@neohm/nh-cli 1.1.5 → 1.3.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.
@@ -0,0 +1,82 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ /**
7
+ * Name-collision guard for the flag generators.
8
+ *
9
+ * `nh gen -j Order` must not quietly land next to an existing OrderJob: the
10
+ * job identifier registries (job.constants / job.maps) are project-wide, so a
11
+ * second class of the same name is a genuine conflict rather than a shadowed
12
+ * local. The same holds for controllers, services and processors, where two
13
+ * identically named providers in one Nest container is a bug that surfaces
14
+ * far from its cause.
15
+ *
16
+ * So before writing anything, look for the target *anywhere* under src/ —
17
+ * by filename and by exported class name — and report what is already there
18
+ * instead of creating a near-duplicate.
19
+ */
20
+
21
+ const SKIP_DIRS = new Set(['node_modules', 'dist', 'coverage', '.git']);
22
+
23
+ /** Every .ts file under `dir`, recursively. */
24
+ function walkTs(dir, out = []) {
25
+ if (!fs.existsSync(dir)) return out;
26
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
27
+ if (entry.isDirectory()) {
28
+ if (SKIP_DIRS.has(entry.name)) continue;
29
+ walkTs(path.join(dir, entry.name), out);
30
+ } else if (entry.isFile() && entry.name.endsWith('.ts')) {
31
+ out.push(path.join(dir, entry.name));
32
+ }
33
+ }
34
+ return out;
35
+ }
36
+
37
+ function exportedClassNames(filePath) {
38
+ const content = fs.readFileSync(filePath, 'utf8');
39
+ return [...content.matchAll(/export\s+(?:abstract\s+)?class\s+(\w+)/g)].map(
40
+ (m) => m[1],
41
+ );
42
+ }
43
+
44
+ /**
45
+ * Look for `className` / `fileName` under srcDir.
46
+ *
47
+ * Returns `null` when the name is free, otherwise
48
+ * `{ kind: 'class' | 'file', file }` describing the first conflict found —
49
+ * a class match is reported ahead of a filename match, since it is the one
50
+ * that actually breaks at runtime.
51
+ */
52
+ function findConflict(srcDir, { className, fileName }) {
53
+ const files = walkTs(srcDir);
54
+
55
+ if (className) {
56
+ for (const file of files) {
57
+ if (exportedClassNames(file).includes(className)) {
58
+ return { kind: 'class', file, name: className };
59
+ }
60
+ }
61
+ }
62
+
63
+ if (fileName) {
64
+ for (const file of files) {
65
+ if (path.basename(file) === fileName) {
66
+ return { kind: 'file', file, name: fileName };
67
+ }
68
+ }
69
+ }
70
+
71
+ return null;
72
+ }
73
+
74
+ /** Human-readable one-liner for a conflict, relative to the project root. */
75
+ function describeConflict(conflict, root) {
76
+ const rel = path.relative(root, conflict.file);
77
+ return conflict.kind === 'class'
78
+ ? `a class named ${conflict.name} already exists in ${rel}`
79
+ : `a file named ${conflict.name} already exists at ${rel}`;
80
+ }
81
+
82
+ module.exports = { walkTs, exportedClassNames, findConflict, describeConflict };
@@ -0,0 +1,106 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ /**
7
+ * Which package the generated code is written against.
8
+ *
9
+ * The two share every convention this CLI encodes — dot.separated filenames,
10
+ * the processor lifecycle, es6.classes barrels, the migration utility — and
11
+ * differ in what a record belongs to. `nestend` is the tenanted model: rows
12
+ * carry a `tenant_id`, access is a scope *and* a role. `nestendx` is the B2C
13
+ * one: users register themselves and hold scopes alone, so a row belongs to the
14
+ * user who created it and access is a scope.
15
+ *
16
+ * `nh` generates the first, `nhx` the second. Everything that does not generate
17
+ * code — sync, check, list, remove — is the same command either way.
18
+ */
19
+ const FLAVOURS = {
20
+ nestend: {
21
+ key: 'nestend',
22
+ cli: 'nh',
23
+ package: '@neohm/nestend',
24
+ /** Rows belong to a tenant; the scope check carries a role beside it. */
25
+ tenantScoped: true,
26
+ },
27
+ nestendx: {
28
+ key: 'nestendx',
29
+ cli: 'nhx',
30
+ package: '@neohm/nestendx',
31
+ tenantScoped: false,
32
+ },
33
+ };
34
+
35
+ let active = FLAVOURS.nestend;
36
+
37
+ /** Set by the bin entrypoint before anything else is required. */
38
+ function setFlavour(key) {
39
+ const flavour = FLAVOURS[key];
40
+ if (!flavour) throw new Error(`Unknown flavour "${key}".`);
41
+ active = flavour;
42
+ return active;
43
+ }
44
+
45
+ function current() {
46
+ return active;
47
+ }
48
+
49
+ /** The package specifier generated imports are written against. */
50
+ function pkg() {
51
+ return active.package;
52
+ }
53
+
54
+ /** The command the person actually typed, for help text and hints. */
55
+ function cli() {
56
+ return active.cli;
57
+ }
58
+
59
+ /**
60
+ * Which package a project depends on, read from its package.json. Returns null
61
+ * when it depends on neither — a project that has not installed one yet is not
62
+ * an error, it is simply not evidence either way.
63
+ */
64
+ function detectFlavour(projectRoot) {
65
+ const manifest = path.join(projectRoot, 'package.json');
66
+ let deps;
67
+ try {
68
+ const parsed = JSON.parse(fs.readFileSync(manifest, 'utf8'));
69
+ deps = { ...parsed.dependencies, ...parsed.devDependencies };
70
+ } catch {
71
+ return null;
72
+ }
73
+
74
+ // nestendx first: "@neohm/nestend" is a prefix of it, and a project can only
75
+ // sensibly depend on one.
76
+ if (deps[FLAVOURS.nestendx.package]) return FLAVOURS.nestendx.key;
77
+ if (deps[FLAVOURS.nestend.package]) return FLAVOURS.nestend.key;
78
+ return null;
79
+ }
80
+
81
+ /**
82
+ * Refuse to scaffold against the wrong package. Generating tenanted code into a
83
+ * nestendx project produces files that import a package that is not there and
84
+ * columns no table has — a failure the person would meet later, in a compiler
85
+ * error, with nothing pointing back here.
86
+ */
87
+ function assertFlavourMatchesProject(projectRoot) {
88
+ const detected = detectFlavour(projectRoot);
89
+ if (!detected || detected === active.key) return;
90
+
91
+ const wanted = FLAVOURS[detected];
92
+ throw new Error(
93
+ `This project depends on ${wanted.package}, but you ran ${active.cli}, ` +
94
+ `which generates ${active.package} code.\n Use \`${wanted.cli}\` here instead.`,
95
+ );
96
+ }
97
+
98
+ module.exports = {
99
+ FLAVOURS,
100
+ setFlavour,
101
+ current,
102
+ pkg,
103
+ cli,
104
+ detectFlavour,
105
+ assertFlavourMatchesProject,
106
+ };
@@ -0,0 +1,54 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ /**
7
+ * Format generated files with the *target project's* prettier, honouring its
8
+ * own .prettierrc.
9
+ *
10
+ * The boilerplate lints with `prettier/prettier: error`, so a scaffold that is
11
+ * merely valid TypeScript is not good enough — it has to land already
12
+ * formatted, or `yarn lint` fails on freshly generated code. Templates aim to
13
+ * emit prettier-clean output on their own; this is the backstop that keeps
14
+ * that true for entity names long enough to push a line past 80 columns.
15
+ *
16
+ * Best-effort by design: a project without prettier installed still gets its
17
+ * files, just unformatted.
18
+ */
19
+ function loadPrettier(root) {
20
+ try {
21
+ return require(require.resolve('prettier', { paths: [root] }));
22
+ } catch {
23
+ return null;
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Format `files` in place. Returns the count actually rewritten, or null when
29
+ * prettier could not be resolved from the project.
30
+ */
31
+ async function formatFiles(files, root) {
32
+ const prettier = loadPrettier(root);
33
+ if (!prettier) return null;
34
+
35
+ let changed = 0;
36
+ for (const file of new Set(files)) {
37
+ if (!file || !fs.existsSync(file)) continue;
38
+ if (!['.ts', '.js', '.json'].includes(path.extname(file))) continue;
39
+ try {
40
+ const source = fs.readFileSync(file, 'utf8');
41
+ const config = (await prettier.resolveConfig(file)) || {};
42
+ const next = await prettier.format(source, { ...config, filepath: file });
43
+ if (next !== source) {
44
+ fs.writeFileSync(file, next);
45
+ changed++;
46
+ }
47
+ } catch {
48
+ // A file prettier cannot parse is left exactly as generated.
49
+ }
50
+ }
51
+ return changed;
52
+ }
53
+
54
+ module.exports = { formatFiles, loadPrettier };
@@ -0,0 +1,120 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+
5
+ const tpl = require('./templates');
6
+ const { log } = require('./fs.utils');
7
+ const {
8
+ ensureConstantEntry,
9
+ ensureMapEntry,
10
+ detectExportName,
11
+ entityConstantsPath,
12
+ } = require('./constants.engine');
13
+
14
+ /**
15
+ * The project-wide identifier registries.
16
+ *
17
+ * Entities and jobs are addressed by string identifier at runtime, so each one
18
+ * has to appear twice: as `<name>: '<dot.name>'` in the constants file, and as
19
+ * `[Constants.<name>]: <Class>` in the map beside it. A generated job
20
+ * references `JobConstants.<name>Job` directly, so a missing entry is a
21
+ * compile error rather than a latent one.
22
+ *
23
+ * Shared by the `nh gen` wizard and the `nh gen -e` / `nh gen -j` flags so
24
+ * both register identically. Registration is best-effort: a registry the
25
+ * engine cannot parse produces a warning telling you the one line to add, not
26
+ * a failed run that has already written half a module.
27
+ */
28
+
29
+ /** Register `<base>Job` in job.constants.ts and job.maps.ts. */
30
+ function registerJob(paths, base, moduleDot, report) {
31
+ const n = tpl.names(base);
32
+
33
+ const constantsPath = path.join(paths.constantsDir, 'job.constants.ts');
34
+ const entry = ensureConstantEntry(
35
+ constantsPath,
36
+ tpl.jobConstantsFile(base),
37
+ `${n.camel}Job`,
38
+ `${n.dot}.job`,
39
+ );
40
+ if (entry === 'failed') {
41
+ log.warn(
42
+ `Could not update ${path.relative(paths.root, constantsPath)} — add ${n.camel}Job: '${n.dot}.job' manually.`,
43
+ );
44
+ } else {
45
+ report(entry, constantsPath, paths.root);
46
+ }
47
+
48
+ const constantsRef = {
49
+ exportName: detectExportName(constantsPath) || 'JobConstants',
50
+ importPath: './job.constants',
51
+ };
52
+ const mapsPath = path.join(paths.constantsDir, 'job.maps.ts');
53
+ const mapEntry = ensureMapEntry(
54
+ mapsPath,
55
+ tpl.jobMapsFile(base, moduleDot, constantsRef),
56
+ `[${constantsRef.exportName}.${n.camel}Job]`,
57
+ n.jobClass,
58
+ [
59
+ { className: n.jobClass, importPath: `../${moduleDot}/jobs/${n.dot}.job` },
60
+ { className: constantsRef.exportName, importPath: constantsRef.importPath },
61
+ ],
62
+ );
63
+ if (mapEntry === 'failed') {
64
+ log.warn(
65
+ `Could not update ${path.relative(paths.root, mapsPath)} — map ${n.jobClass} manually.`,
66
+ );
67
+ } else {
68
+ report(mapEntry, mapsPath, paths.root);
69
+ }
70
+ }
71
+
72
+ /** Register `<base>` in entity.constants.ts and entity.maps.ts. */
73
+ function registerEntity(paths, base, moduleDot, report) {
74
+ const n = tpl.names(base);
75
+
76
+ // The boilerplate ships this file misspelled in some projects, so the
77
+ // engine reports whichever spelling is actually on disk.
78
+ const constantsPath = entityConstantsPath(paths.constantsDir);
79
+ const entry = ensureConstantEntry(
80
+ constantsPath,
81
+ tpl.entityConstantsFile(base),
82
+ n.camel,
83
+ n.dot,
84
+ );
85
+ if (entry === 'failed') {
86
+ log.warn(
87
+ `Could not update ${path.relative(paths.root, constantsPath)} — add ${n.camel}: '${n.dot}' manually.`,
88
+ );
89
+ } else {
90
+ report(entry, constantsPath, paths.root);
91
+ }
92
+
93
+ const constantsRef = {
94
+ exportName: detectExportName(constantsPath) || 'EntityConstants',
95
+ importPath: `./${path.basename(constantsPath, '.ts')}`,
96
+ };
97
+ const mapsPath = path.join(paths.constantsDir, 'entity.maps.ts');
98
+ const mapEntry = ensureMapEntry(
99
+ mapsPath,
100
+ tpl.entityMapsFile(base, moduleDot, constantsRef),
101
+ `[${constantsRef.exportName}.${n.camel}]`,
102
+ n.entityClass,
103
+ [
104
+ {
105
+ className: n.entityClass,
106
+ importPath: `../${moduleDot}/entities/${n.dot}.entity`,
107
+ },
108
+ { className: constantsRef.exportName, importPath: constantsRef.importPath },
109
+ ],
110
+ );
111
+ if (mapEntry === 'failed') {
112
+ log.warn(
113
+ `Could not update ${path.relative(paths.root, mapsPath)} — map ${n.entityClass} manually.`,
114
+ );
115
+ } else {
116
+ report(mapEntry, mapsPath, paths.root);
117
+ }
118
+ }
119
+
120
+ module.exports = { registerJob, registerEntity };
@@ -27,6 +27,14 @@ const CATEGORIES = [
27
27
  // Files in a category folder that are not providers themselves.
28
28
  const NON_PROVIDER_FILES = new Set(['es6.classes.ts', 'index.ts']);
29
29
 
30
+ // A module may expose an API-key surface from `<module>/external/<folder>`
31
+ // (e.g. external/controllers/external.test.controller.ts). Those classes are
32
+ // providers of the same categories as their internal siblings and belong in
33
+ // the same es6.classes arrays — listed after the internal ones — so the
34
+ // scanner has to walk both roots or `nh sync` would strip them from the
35
+ // barrel.
36
+ const EXTERNAL_DIR = 'external';
37
+
30
38
  function exportedClassName(filePath) {
31
39
  const content = fs.readFileSync(filePath, 'utf8');
32
40
  const match = content.match(/export\s+class\s+(\w+)/);
@@ -80,12 +88,17 @@ function scanModule(dir, modFile) {
80
88
  const moduleDot = path.basename(dir);
81
89
  const providers = {};
82
90
  for (const cat of CATEGORIES) {
83
- providers[cat.key] = listClassFiles(path.join(dir, cat.folder)).map(
84
- (file) => ({
91
+ // Internal first, then the external/ mirror of the same category.
92
+ const roots = [
93
+ { base: dir, prefix: '' },
94
+ { base: path.join(dir, EXTERNAL_DIR), prefix: `${EXTERNAL_DIR}/` },
95
+ ];
96
+ providers[cat.key] = roots.flatMap(({ base, prefix }) =>
97
+ listClassFiles(path.join(base, cat.folder)).map((file) => ({
85
98
  file,
86
99
  className: exportedClassName(file),
87
- importPath: `./${cat.folder}/${path.basename(file, '.ts')}`,
88
- }),
100
+ importPath: `./${prefix}${cat.folder}/${path.basename(file, '.ts')}`,
101
+ })),
89
102
  );
90
103
  }
91
104
  return {
@@ -101,7 +114,15 @@ function scanModule(dir, modFile) {
101
114
  };
102
115
  }
103
116
 
104
- /** Render the es6.classes.ts content for a scanned module. */
117
+ /**
118
+ * Render the es6.classes.ts content for a scanned module.
119
+ *
120
+ * The array literals are wrapped exactly the way prettier's 80-column printer
121
+ * would: inline while they fit, one class per line once they don't. `nh gen`
122
+ * formats what it writes, so if this emitted a single long line instead,
123
+ * every later `nh check` would report the barrel as drifted purely because
124
+ * prettier had reflowed it.
125
+ */
105
126
  function buildEs6Content(mod) {
106
127
  const importLines = [];
107
128
  const arrayLines = [];
@@ -110,8 +131,13 @@ function buildEs6Content(mod) {
110
131
  for (const e of entries) {
111
132
  importLines.push(`import { ${e.className} } from '${e.importPath}';`);
112
133
  }
113
- const classes = entries.map((e) => e.className).join(', ');
114
- arrayLines.push(` ${cat.key}: [${classes}],`);
134
+ const classes = entries.map((e) => e.className);
135
+ const inline = ` ${cat.key}: [${classes.join(', ')}],`;
136
+ arrayLines.push(
137
+ inline.length <= 80
138
+ ? inline
139
+ : ` ${cat.key}: [\n${classes.map((c) => ` ${c},`).join('\n')}\n ],`,
140
+ );
115
141
  }
116
142
  const header = importLines.length ? importLines.join('\n') + '\n\n' : '';
117
143
  return `${header}export const es6Classes = {\n${arrayLines.join('\n')}\n};\n`;
@@ -233,6 +259,7 @@ function applyPlan(plan) {
233
259
 
234
260
  module.exports = {
235
261
  CATEGORIES,
262
+ EXTERNAL_DIR,
236
263
  scanModules,
237
264
  scanModule,
238
265
  buildEs6Content,