@neohm/nh-cli 1.3.1 → 1.4.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 +39 -7
- package/package.json +1 -1
- package/src/commands/gen.js +12 -2
- package/src/lib/access.js +38 -0
- package/src/lib/constants.engine.js +65 -51
- package/src/lib/paths.js +1 -0
- package/src/lib/registry.js +59 -1
- package/src/lib/source.scan.js +132 -0
- package/src/lib/sync.engine.js +32 -14
- package/src/lib/templates.js +85 -15
package/README.md
CHANGED
|
@@ -105,7 +105,8 @@ shape/
|
|
|
105
105
|
```
|
|
106
106
|
|
|
107
107
|
plus `src/database/migrations/<timestamp>M-AddShapeTable.ts`, and it
|
|
108
|
-
registers `ShapeModule` in `src/app.module.ts
|
|
108
|
+
registers `ShapeModule` in `src/app.module.ts` — appended to the `@Module()`
|
|
109
|
+
`imports` array without disturbing comments inside it.
|
|
109
110
|
|
|
110
111
|
It also registers the entity and its job in the project constant registries —
|
|
111
112
|
`shapeJob: 'shape.job'` in `src/constants/job.constants.ts` (which the
|
|
@@ -147,12 +148,14 @@ answers you already gave:
|
|
|
147
148
|
| Service method | Generated when | Controller endpoint |
|
|
148
149
|
| --- | --- | --- |
|
|
149
150
|
| `get(user, id)` | always | `GET /api/v1/shape/:id` |
|
|
151
|
+
| `remove(user, id)` | always | `DELETE /api/v1/shape/:id` |
|
|
150
152
|
| `upsert(user, data)` | data processor: Yes | `POST /api/v1/shape` |
|
|
151
153
|
| `list(user, data)` | listing processor: Yes | `POST /api/v1/shape/list` |
|
|
152
154
|
|
|
153
|
-
`get()`
|
|
154
|
-
|
|
155
|
-
|
|
155
|
+
`get()` and `remove()` keep their logic in the service — no processor: `remove()`
|
|
156
|
+
soft-deletes through the same owner-scoped lookup as `get()`. A module that
|
|
157
|
+
declines both processors still gets a service that reads and deletes (and a
|
|
158
|
+
controller with just the `GET` and `DELETE`). Declining a processor drops the matching service method
|
|
156
159
|
*and* its endpoint together, so the generated module always compiles.
|
|
157
160
|
|
|
158
161
|
`upsert()` rather than `create()` because `CommonDataDto` carries an optional
|
|
@@ -168,9 +171,38 @@ is what the tenant is called on `UserEntity`; `tenant_id` is what it is called
|
|
|
168
171
|
on the row — the same value on two sides of one relation.) The listing
|
|
169
172
|
processor applies the same scope as a private predicate.
|
|
170
173
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
`
|
|
174
|
+
### Scope and role enums
|
|
175
|
+
|
|
176
|
+
`nh gen` registers the entity's scope and default roles in auto-maintained enums
|
|
177
|
+
under `src/global/enums/` (created if missing, appended to otherwise — comments
|
|
178
|
+
and existing members are left alone). For entity `Billing`:
|
|
179
|
+
|
|
180
|
+
```ts
|
|
181
|
+
// scope.enum.ts
|
|
182
|
+
export enum ScopeEnum {
|
|
183
|
+
BILLING = 'billing',
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// role.enum.ts (nh only — nhx has no roles)
|
|
187
|
+
export enum RoleEnum {
|
|
188
|
+
BILLING_ADMIN = 'billing.admin', // priority 1 — the scope's top role
|
|
189
|
+
BILLING_MANAGER = 'billing.manager', // priority 2
|
|
190
|
+
BILLING_READER = 'billing.reader', // priority 3
|
|
191
|
+
}
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
The controller gates each endpoint at the lowest role that should reach it — a
|
|
195
|
+
senior role satisfies a junior one in nestend's scoped RBAC:
|
|
196
|
+
|
|
197
|
+
| Endpoint | Gate |
|
|
198
|
+
| --- | --- |
|
|
199
|
+
| `GET :id`, `POST list` | `validateAccess(ScopeEnum.BILLING, RoleEnum.BILLING_READER)` |
|
|
200
|
+
| `POST` (upsert) | `validateAccess(ScopeEnum.BILLING, RoleEnum.BILLING_MANAGER)` |
|
|
201
|
+
| `DELETE :id` | `validateAccess(ScopeEnum.BILLING, RoleEnum.BILLING_ADMIN)` |
|
|
202
|
+
|
|
203
|
+
Under `nhx` the gate is `validateAccess(ScopeEnum.BILLING)`. The enums name the
|
|
204
|
+
identifiers only: create the `sys_scopes` row and the three `sys_roles` rows
|
|
205
|
+
(scope `billing`, priorities 1/2/3) in the back office so the checks can pass.
|
|
174
206
|
|
|
175
207
|
## Flag generation
|
|
176
208
|
|
package/package.json
CHANGED
package/src/commands/gen.js
CHANGED
|
@@ -12,7 +12,11 @@ const tpl = require("../lib/templates");
|
|
|
12
12
|
const { writeFile, report: reportFile, log, ensureDir } = require("../lib/fs.utils");
|
|
13
13
|
const sync = require("../lib/sync.engine");
|
|
14
14
|
const { formatFiles } = require("../lib/format");
|
|
15
|
-
const {
|
|
15
|
+
const {
|
|
16
|
+
registerJob,
|
|
17
|
+
registerEntity,
|
|
18
|
+
registerAccess,
|
|
19
|
+
} = require("../lib/registry");
|
|
16
20
|
|
|
17
21
|
async function gen() {
|
|
18
22
|
const paths = projectPaths();
|
|
@@ -103,6 +107,9 @@ async function gen() {
|
|
|
103
107
|
// (the generated job references JobConstants.<name>Job, so the entry must exist).
|
|
104
108
|
registerJob(paths, entityName, moduleDot, report);
|
|
105
109
|
registerEntity(paths, entityName, moduleDot, report);
|
|
110
|
+
// The entity's scope and, tenanted, its default roles (admin > manager >
|
|
111
|
+
// reader) in src/global/enums — what the generated controller gates on.
|
|
112
|
+
const enums = registerAccess(paths, entityName, report);
|
|
106
113
|
|
|
107
114
|
// 4. Optional: controller -------------------------------------------------
|
|
108
115
|
const { controller } = await inquirer.prompt([
|
|
@@ -167,7 +174,10 @@ async function gen() {
|
|
|
167
174
|
if (controller) {
|
|
168
175
|
const p = path.join(moduleDir, "controllers", n.controllerFile);
|
|
169
176
|
report(
|
|
170
|
-
writeFile(
|
|
177
|
+
writeFile(
|
|
178
|
+
p,
|
|
179
|
+
tpl.controller(entityName, { processor, listProcessor, enums }),
|
|
180
|
+
),
|
|
171
181
|
p,
|
|
172
182
|
paths.root,
|
|
173
183
|
);
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { toWords, toDotCase } = require('./naming');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The scope and default roles `nh gen` registers for an entity. Roles rank
|
|
7
|
+
* within the scope by priority (1 = most senior) and a senior role satisfies a
|
|
8
|
+
* junior requirement, so each endpoint is gated at the lowest role that should
|
|
9
|
+
* reach it: reader for get/list, manager for upsert, admin for delete. The
|
|
10
|
+
* admin role is the scope's top role — its holders grant the scope's roles.
|
|
11
|
+
*/
|
|
12
|
+
const ROLE_LEVELS = [
|
|
13
|
+
{ level: 'admin', priority: 1 },
|
|
14
|
+
{ level: 'manager', priority: 2 },
|
|
15
|
+
{ level: 'reader', priority: 3 },
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
/** BILLING_ITEM */
|
|
19
|
+
function toConstantCase(input) {
|
|
20
|
+
return toWords(input).join('_').toUpperCase();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Enum keys and identifiers for `base`, e.g. Billing → BILLING: 'billing'. */
|
|
24
|
+
function accessNames(base) {
|
|
25
|
+
const scopeKey = toConstantCase(base);
|
|
26
|
+
const scopeValue = toDotCase(base);
|
|
27
|
+
const roles = {};
|
|
28
|
+
for (const { level, priority } of ROLE_LEVELS) {
|
|
29
|
+
roles[level] = {
|
|
30
|
+
key: `${scopeKey}_${level.toUpperCase()}`,
|
|
31
|
+
value: `${scopeValue}.${level}`,
|
|
32
|
+
priority,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
return { scopeKey, scopeValue, roles };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
module.exports = { ROLE_LEVELS, toConstantCase, accessNames };
|
|
@@ -4,6 +4,7 @@ const fs = require('fs');
|
|
|
4
4
|
const path = require('path');
|
|
5
5
|
|
|
6
6
|
const { writeFile } = require('./fs.utils');
|
|
7
|
+
const scan = require('./source.scan');
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Keeps the project-level constant registries (src/constants/job.constants.ts
|
|
@@ -26,36 +27,14 @@ function ensureConstantEntry(filePath, freshContent, key, value) {
|
|
|
26
27
|
}
|
|
27
28
|
|
|
28
29
|
const content = fs.readFileSync(filePath, 'utf8');
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
// First exported object literal in the file, e.g. `export const JobConstants = {`.
|
|
34
|
-
const open = content.search(/export\s+const\s+\w+\s*(?::[^=]+)?=\s*{/);
|
|
35
|
-
if (open === -1) return 'failed';
|
|
36
|
-
const braceStart = content.indexOf('{', open);
|
|
37
|
-
|
|
38
|
-
// Walk to the matching closing brace (entries are flat string values, but
|
|
39
|
-
// stay correct if a nested object ever appears).
|
|
40
|
-
let depth = 0;
|
|
41
|
-
let close = -1;
|
|
42
|
-
for (let i = braceStart; i < content.length; i++) {
|
|
43
|
-
if (content[i] === '{') depth++;
|
|
44
|
-
else if (content[i] === '}') {
|
|
45
|
-
depth--;
|
|
46
|
-
if (depth === 0) {
|
|
47
|
-
close = i;
|
|
48
|
-
break;
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
if (close === -1) return 'failed';
|
|
30
|
+
const literal = exportedLiteral(content, EXPORT_OBJECT);
|
|
31
|
+
if (!literal) return 'failed';
|
|
32
|
+
if (hasMember(content, literal, `${escapeRe(key)}\\s*:`)) return 'skipped';
|
|
53
33
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
fs.writeFileSync(filePath, next);
|
|
34
|
+
fs.writeFileSync(
|
|
35
|
+
filePath,
|
|
36
|
+
scan.appendMember(content, literal.open, literal.close, `${key}: '${value}',`, ' '),
|
|
37
|
+
);
|
|
59
38
|
return 'overwritten';
|
|
60
39
|
}
|
|
61
40
|
|
|
@@ -71,40 +50,64 @@ function ensureMapEntry(filePath, freshContent, entryKey, entryValue, imports) {
|
|
|
71
50
|
}
|
|
72
51
|
|
|
73
52
|
let content = fs.readFileSync(filePath, 'utf8');
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
if (open === -1) return 'failed';
|
|
53
|
+
const existing = exportedLiteral(content, EXPORT_OBJECT);
|
|
54
|
+
if (!existing) return 'failed';
|
|
55
|
+
if (hasMember(content, existing, escapeRe(entryKey))) return 'skipped';
|
|
78
56
|
|
|
79
57
|
for (const imp of imports) {
|
|
80
58
|
content = ensureImportLine(content, imp.className, imp.importPath);
|
|
81
59
|
}
|
|
82
60
|
|
|
83
61
|
// Re-locate the object after import insertion shifted offsets.
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
62
|
+
const literal = exportedLiteral(content, EXPORT_OBJECT);
|
|
63
|
+
fs.writeFileSync(
|
|
64
|
+
filePath,
|
|
65
|
+
scan.appendMember(content, literal.open, literal.close, `${entryKey}: ${entryValue},`, ' '),
|
|
87
66
|
);
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
67
|
+
return 'overwritten';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Ensure `KEY = 'value',` is a member of the enum exported by `filePath`.
|
|
72
|
+
* Creates the file with `freshContent` when absent. Returns 'created' |
|
|
73
|
+
* 'skipped' | 'overwritten' | 'failed'.
|
|
74
|
+
*/
|
|
75
|
+
function ensureEnumMember(filePath, freshContent, key, value) {
|
|
76
|
+
if (!fs.existsSync(filePath)) {
|
|
77
|
+
return writeFile(filePath, freshContent);
|
|
99
78
|
}
|
|
100
|
-
if (close === -1) return 'failed';
|
|
101
79
|
|
|
102
|
-
const
|
|
103
|
-
const
|
|
104
|
-
|
|
80
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
81
|
+
const literal = exportedLiteral(content, EXPORT_ENUM);
|
|
82
|
+
if (!literal) return 'failed';
|
|
83
|
+
if (hasMember(content, literal, `${escapeRe(key)}\\s*=`)) return 'skipped';
|
|
84
|
+
|
|
85
|
+
fs.writeFileSync(
|
|
86
|
+
filePath,
|
|
87
|
+
scan.appendMember(content, literal.open, literal.close, `${key} = '${value}',`, ' '),
|
|
88
|
+
);
|
|
105
89
|
return 'overwritten';
|
|
106
90
|
}
|
|
107
91
|
|
|
92
|
+
const EXPORT_OBJECT = /export\s+const\s+\w+\s*(?::[^=]+)?=\s*{/;
|
|
93
|
+
const EXPORT_ENUM = /export\s+enum\s+\w+\s*{/;
|
|
94
|
+
|
|
95
|
+
/** Brace positions of the first exported literal matching `declaration`; null if none. */
|
|
96
|
+
function exportedLiteral(content, declaration) {
|
|
97
|
+
const mask = scan.codeMask(content);
|
|
98
|
+
const found = scan.findInCode(content, declaration, 0, mask);
|
|
99
|
+
if (!found) return null;
|
|
100
|
+
const open = content.indexOf('{', found.index);
|
|
101
|
+
const close = scan.matchingClose(content, open, mask);
|
|
102
|
+
return close === -1 ? null : { open, close };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Whether the literal's code (comments ignored) has a member starting with `pattern`. */
|
|
106
|
+
function hasMember(content, literal, pattern) {
|
|
107
|
+
const inner = scan.codeOnly(content, literal.open + 1, literal.close);
|
|
108
|
+
return new RegExp(`(^|[\\s,{])${pattern}`).test(inner);
|
|
109
|
+
}
|
|
110
|
+
|
|
108
111
|
function ensureImportLine(content, className, importPath) {
|
|
109
112
|
if (new RegExp(`from\\s+['"]${escapeRe(importPath)}['"]`).test(content)) {
|
|
110
113
|
return content;
|
|
@@ -131,6 +134,15 @@ function detectExportName(filePath) {
|
|
|
131
134
|
return match ? match[1] : null;
|
|
132
135
|
}
|
|
133
136
|
|
|
137
|
+
/** Name of the first `export enum X {` in the file (null if unreadable). */
|
|
138
|
+
function detectEnumName(filePath) {
|
|
139
|
+
if (!fs.existsSync(filePath)) return null;
|
|
140
|
+
const match = fs
|
|
141
|
+
.readFileSync(filePath, 'utf8')
|
|
142
|
+
.match(/export\s+enum\s+(\w+)\s*{/);
|
|
143
|
+
return match ? match[1] : null;
|
|
144
|
+
}
|
|
145
|
+
|
|
134
146
|
/**
|
|
135
147
|
* Resolve the entity constants file, tolerating the legacy misspelled
|
|
136
148
|
* `enity.constants.ts` some projects carry — an existing registry is updated
|
|
@@ -150,6 +162,8 @@ function escapeRe(str) {
|
|
|
150
162
|
module.exports = {
|
|
151
163
|
ensureConstantEntry,
|
|
152
164
|
ensureMapEntry,
|
|
165
|
+
ensureEnumMember,
|
|
153
166
|
detectExportName,
|
|
167
|
+
detectEnumName,
|
|
154
168
|
entityConstantsPath,
|
|
155
169
|
};
|
package/src/lib/paths.js
CHANGED
|
@@ -41,6 +41,7 @@ function projectPaths(root = requireProjectRoot()) {
|
|
|
41
41
|
appModule: path.join(srcDir, 'app.module.ts'),
|
|
42
42
|
migrationsDir: path.join(srcDir, 'database', 'migrations'),
|
|
43
43
|
constantsDir: path.join(srcDir, 'constants'),
|
|
44
|
+
enumsDir: path.join(srcDir, 'global', 'enums'),
|
|
44
45
|
moduleDir: (moduleDot) => path.join(srcDir, moduleDot),
|
|
45
46
|
};
|
|
46
47
|
}
|
package/src/lib/registry.js
CHANGED
|
@@ -7,9 +7,13 @@ const { log } = require('./fs.utils');
|
|
|
7
7
|
const {
|
|
8
8
|
ensureConstantEntry,
|
|
9
9
|
ensureMapEntry,
|
|
10
|
+
ensureEnumMember,
|
|
10
11
|
detectExportName,
|
|
12
|
+
detectEnumName,
|
|
11
13
|
entityConstantsPath,
|
|
12
14
|
} = require('./constants.engine');
|
|
15
|
+
const { accessNames } = require('./access');
|
|
16
|
+
const { current } = require('./flavour');
|
|
13
17
|
|
|
14
18
|
/**
|
|
15
19
|
* The project-wide identifier registries.
|
|
@@ -117,4 +121,58 @@ function registerEntity(paths, base, moduleDot, report) {
|
|
|
117
121
|
}
|
|
118
122
|
}
|
|
119
123
|
|
|
120
|
-
|
|
124
|
+
/**
|
|
125
|
+
* Register `<base>`'s scope in src/global/enums/scope.enum.ts and, for the
|
|
126
|
+
* tenanted flavour, its default roles (admin, manager, reader) in role.enum.ts.
|
|
127
|
+
* Returns the enum names the generated controller imports.
|
|
128
|
+
*/
|
|
129
|
+
function registerAccess(paths, base, report) {
|
|
130
|
+
const a = accessNames(base);
|
|
131
|
+
const enums = { scope: 'ScopeEnum', role: 'RoleEnum' };
|
|
132
|
+
|
|
133
|
+
const scopePath = path.join(paths.enumsDir, 'scope.enum.ts');
|
|
134
|
+
const scope = ensureEnumMember(
|
|
135
|
+
scopePath,
|
|
136
|
+
tpl.scopeEnumFile(base),
|
|
137
|
+
a.scopeKey,
|
|
138
|
+
a.scopeValue,
|
|
139
|
+
);
|
|
140
|
+
if (scope === 'failed')
|
|
141
|
+
log.warn(
|
|
142
|
+
`Could not update ${path.relative(paths.root, scopePath)} — add ${a.scopeKey} = '${a.scopeValue}' manually.`,
|
|
143
|
+
);
|
|
144
|
+
else report(scope, scopePath, paths.root);
|
|
145
|
+
enums.scope = detectEnumName(scopePath) || enums.scope;
|
|
146
|
+
|
|
147
|
+
// The B2C model has scopes and no roles.
|
|
148
|
+
if (!current().tenantScoped) return enums;
|
|
149
|
+
|
|
150
|
+
const rolePath = path.join(paths.enumsDir, 'role.enum.ts');
|
|
151
|
+
const results = [];
|
|
152
|
+
for (const role of Object.values(a.roles)) {
|
|
153
|
+
const result = ensureEnumMember(
|
|
154
|
+
rolePath,
|
|
155
|
+
tpl.roleEnumFile(base),
|
|
156
|
+
role.key,
|
|
157
|
+
role.value,
|
|
158
|
+
);
|
|
159
|
+
if (result === 'failed') {
|
|
160
|
+
log.warn(
|
|
161
|
+
`Could not update ${path.relative(paths.root, rolePath)} — add ${role.key} = '${role.value}' manually.`,
|
|
162
|
+
);
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
results.push(result);
|
|
166
|
+
}
|
|
167
|
+
// A fresh file already carries every default role, so later calls skip.
|
|
168
|
+
const summary = results.includes('created')
|
|
169
|
+
? 'created'
|
|
170
|
+
: results.includes('overwritten')
|
|
171
|
+
? 'overwritten'
|
|
172
|
+
: 'skipped';
|
|
173
|
+
if (results.length) report(summary, rolePath, paths.root);
|
|
174
|
+
enums.role = detectEnumName(rolePath) || enums.role;
|
|
175
|
+
return enums;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
module.exports = { registerJob, registerEntity, registerAccess };
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Comment- and string-aware scanning of TypeScript source, so the CLI can edit
|
|
5
|
+
* array and object literals without being fooled by a `,`, `]` or `}` that sits
|
|
6
|
+
* inside a comment or a string. Every edit that inserts into an existing literal
|
|
7
|
+
* goes through here.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** `mask[i]` is true when `content[i]` is code (not inside a comment or string). */
|
|
11
|
+
function codeMask(content) {
|
|
12
|
+
return scanMask(content, true);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** `mask[i]` is true unless `content[i]` is inside a comment (strings count). */
|
|
16
|
+
function commentMask(content) {
|
|
17
|
+
return scanMask(content, false);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function scanMask(content, maskStrings) {
|
|
21
|
+
const mask = new Array(content.length).fill(true);
|
|
22
|
+
let i = 0;
|
|
23
|
+
while (i < content.length) {
|
|
24
|
+
const ch = content[i];
|
|
25
|
+
const next = content[i + 1];
|
|
26
|
+
let end = -1;
|
|
27
|
+
let isString = false;
|
|
28
|
+
if (ch === '/' && next === '/') {
|
|
29
|
+
end = content.indexOf('\n', i);
|
|
30
|
+
if (end === -1) end = content.length;
|
|
31
|
+
} else if (ch === '/' && next === '*') {
|
|
32
|
+
end = content.indexOf('*/', i + 2);
|
|
33
|
+
end = end === -1 ? content.length : end + 2;
|
|
34
|
+
} else if (ch === "'" || ch === '"' || ch === '`') {
|
|
35
|
+
isString = true;
|
|
36
|
+
end = i + 1;
|
|
37
|
+
while (end < content.length && content[end] !== ch) {
|
|
38
|
+
if (content[end] === '\\') end++;
|
|
39
|
+
end++;
|
|
40
|
+
}
|
|
41
|
+
end = Math.min(end + 1, content.length);
|
|
42
|
+
}
|
|
43
|
+
if (end === -1) {
|
|
44
|
+
i++;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (!isString || maskStrings)
|
|
48
|
+
for (let k = i; k < end; k++) mask[k] = false;
|
|
49
|
+
i = end;
|
|
50
|
+
}
|
|
51
|
+
return mask;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** First match of `regex` (global flag added) whose start is code; -1 if none. */
|
|
55
|
+
function findInCode(content, regex, from = 0, mask = codeMask(content)) {
|
|
56
|
+
const re = new RegExp(regex.source, regex.flags.includes('g') ? regex.flags : `${regex.flags}g`);
|
|
57
|
+
re.lastIndex = from;
|
|
58
|
+
let match;
|
|
59
|
+
while ((match = re.exec(content))) {
|
|
60
|
+
if (mask[match.index]) return { index: match.index, match };
|
|
61
|
+
re.lastIndex = match.index + 1;
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Index of the bracket closing the one at `openIdx` (any of `([{`), skipping comments and strings. */
|
|
67
|
+
function matchingClose(content, openIdx, mask = codeMask(content)) {
|
|
68
|
+
const pairs = { '(': ')', '[': ']', '{': '}' };
|
|
69
|
+
const stack = [];
|
|
70
|
+
for (let i = openIdx; i < content.length; i++) {
|
|
71
|
+
if (!mask[i]) continue;
|
|
72
|
+
const ch = content[i];
|
|
73
|
+
if (pairs[ch]) stack.push(pairs[ch]);
|
|
74
|
+
else if (ch === ')' || ch === ']' || ch === '}') {
|
|
75
|
+
if (stack.pop() !== ch) return -1;
|
|
76
|
+
if (!stack.length) return i;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return -1;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Index of the last non-whitespace character `mask` marks true in [from, to); -1 if none. */
|
|
83
|
+
function lastCodeChar(content, from, to, mask = codeMask(content)) {
|
|
84
|
+
for (let i = to - 1; i >= from; i--) {
|
|
85
|
+
if (mask[i] && !/\s/.test(content[i])) return i;
|
|
86
|
+
}
|
|
87
|
+
return -1;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** `content` between `from` and `to` with comments and strings blanked out. */
|
|
91
|
+
function codeOnly(content, from, to, mask = codeMask(content)) {
|
|
92
|
+
let out = '';
|
|
93
|
+
for (let i = from; i < to; i++) out += mask[i] ? content[i] : ' ';
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Insert `entryLine` (no indentation, no trailing newline) as the last member of
|
|
99
|
+
* the literal whose brackets sit at `open`/`close`. A missing comma after the
|
|
100
|
+
* current last member is added right after its code — never inside a trailing
|
|
101
|
+
* comment — and comments in the literal stay exactly where they were.
|
|
102
|
+
*/
|
|
103
|
+
function appendMember(content, open, close, entryLine, indent) {
|
|
104
|
+
const last = lastCodeChar(content, open + 1, close, commentMask(content));
|
|
105
|
+
let next = content;
|
|
106
|
+
let closeAt = close;
|
|
107
|
+
if (last !== -1 && content[last] !== ',' && last !== open) {
|
|
108
|
+
next = next.slice(0, last + 1) + ',' + next.slice(last + 1);
|
|
109
|
+
closeAt++;
|
|
110
|
+
}
|
|
111
|
+
// Insert before the whitespace that leads to the closing bracket.
|
|
112
|
+
let insertAt = closeAt;
|
|
113
|
+
while (insertAt > 0 && /[ \t]/.test(next[insertAt - 1])) insertAt--;
|
|
114
|
+
const closingIndent = next.slice(insertAt, closeAt);
|
|
115
|
+
const lead = next[insertAt - 1] === '\n' ? '' : '\n';
|
|
116
|
+
return (
|
|
117
|
+
next.slice(0, insertAt) +
|
|
118
|
+
`${lead}${indent}${entryLine}\n` +
|
|
119
|
+
closingIndent +
|
|
120
|
+
next.slice(closeAt)
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
module.exports = {
|
|
125
|
+
codeMask,
|
|
126
|
+
commentMask,
|
|
127
|
+
findInCode,
|
|
128
|
+
matchingClose,
|
|
129
|
+
lastCodeChar,
|
|
130
|
+
codeOnly,
|
|
131
|
+
appendMember,
|
|
132
|
+
};
|
package/src/lib/sync.engine.js
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
const fs = require('fs');
|
|
4
4
|
const path = require('path');
|
|
5
5
|
|
|
6
|
+
const scan = require('./source.scan');
|
|
7
|
+
|
|
6
8
|
/**
|
|
7
9
|
* The sync engine is the single brain behind `nh sync`, `nh check` and the
|
|
8
10
|
* auto-wiring step of `nh gen`. It scans scaffolded modules, regenerates each
|
|
@@ -178,21 +180,37 @@ function ensureImportLine(content, className, importPath) {
|
|
|
178
180
|
return lines.join('\n');
|
|
179
181
|
}
|
|
180
182
|
|
|
183
|
+
/**
|
|
184
|
+
* Add `className` to the `imports: [...]` array of the @Module() decorator.
|
|
185
|
+
* The array is located and edited through the comment-aware scanner: splitting
|
|
186
|
+
* it on commas used to break any comment holding a comma, turning the rest of
|
|
187
|
+
* that comment into code.
|
|
188
|
+
*/
|
|
181
189
|
function ensureInImportsArray(content, className) {
|
|
182
|
-
const
|
|
183
|
-
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
if (
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
190
|
+
const mask = scan.codeMask(content);
|
|
191
|
+
const decorator = scan.findInCode(content, /@Module\s*\(/, 0, mask);
|
|
192
|
+
const key = scan.findInCode(
|
|
193
|
+
content,
|
|
194
|
+
/\bimports\s*:/,
|
|
195
|
+
decorator ? decorator.index : 0,
|
|
196
|
+
mask,
|
|
197
|
+
);
|
|
198
|
+
if (!key) return content;
|
|
199
|
+
const open = scan.findInCode(content, /\[/, key.index, mask);
|
|
200
|
+
if (!open) return content;
|
|
201
|
+
const close = scan.matchingClose(content, open.index, mask);
|
|
202
|
+
if (close === -1) return content;
|
|
203
|
+
|
|
204
|
+
const inner = scan.codeOnly(content, open.index + 1, close, mask);
|
|
205
|
+
if (new RegExp(`(^|[^\\w.])${escapeRe(className)}\\b`).test(inner))
|
|
206
|
+
return content;
|
|
207
|
+
return scan.appendMember(
|
|
208
|
+
content,
|
|
209
|
+
open.index,
|
|
210
|
+
close,
|
|
211
|
+
`${className},`,
|
|
212
|
+
' ',
|
|
213
|
+
);
|
|
196
214
|
}
|
|
197
215
|
|
|
198
216
|
function escapeRe(str) {
|
package/src/lib/templates.js
CHANGED
|
@@ -9,6 +9,7 @@ const {
|
|
|
9
9
|
toCamelCase,
|
|
10
10
|
migrationClassName,
|
|
11
11
|
} = require("./naming");
|
|
12
|
+
const { accessNames } = require('./access');
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* Code templates. All generated code imports shared base classes from the
|
|
@@ -134,13 +135,26 @@ function attributesDto(base) {
|
|
|
134
135
|
* delegate to the module's service — so the HTTP surface stays readable and
|
|
135
136
|
* the same operation is reusable from a job, a subscriber or another service.
|
|
136
137
|
*/
|
|
137
|
-
function controller(
|
|
138
|
+
function controller(
|
|
139
|
+
base,
|
|
140
|
+
{ processor = false, listProcessor = false, enums = {} } = {},
|
|
141
|
+
) {
|
|
138
142
|
const n = names(base);
|
|
143
|
+
const a = accessNames(base);
|
|
144
|
+
const scopeEnum = enums.scope || 'ScopeEnum';
|
|
145
|
+
const roleEnum = enums.role || 'RoleEnum';
|
|
139
146
|
const svcProp = `${n.camel}Service`;
|
|
140
147
|
const hasPost = processor || listProcessor;
|
|
141
148
|
|
|
142
149
|
// @nestjs/common members, alphabetised the way prettier leaves them.
|
|
143
|
-
const nestCommon = [
|
|
150
|
+
const nestCommon = [
|
|
151
|
+
'Controller',
|
|
152
|
+
'Delete',
|
|
153
|
+
'Get',
|
|
154
|
+
'Param',
|
|
155
|
+
'ParseUUIDPipe',
|
|
156
|
+
'UsePipes',
|
|
157
|
+
];
|
|
144
158
|
if (hasPost) nestCommon.push('Body', 'Post');
|
|
145
159
|
nestCommon.sort();
|
|
146
160
|
|
|
@@ -164,19 +178,35 @@ function controller(base, { processor = false, listProcessor = false } = {}) {
|
|
|
164
178
|
);
|
|
165
179
|
}
|
|
166
180
|
|
|
167
|
-
// Tenanted access takes
|
|
168
|
-
//
|
|
181
|
+
// Tenanted access takes the entity's scope and the lowest role that should
|
|
182
|
+
// reach the endpoint (a senior role satisfies a junior one); the B2C model
|
|
183
|
+
// has no roles, so the same call carries the scope alone.
|
|
169
184
|
const tenanted = current().tenantScoped;
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
185
|
+
imports.push(
|
|
186
|
+
`import { ${scopeEnum} } from '../../global/enums/scope.enum';`,
|
|
187
|
+
);
|
|
188
|
+
if (tenanted)
|
|
189
|
+
imports.push(`import { ${roleEnum} } from '../../global/enums/role.enum';`);
|
|
190
|
+
const scopeRef = `${scopeEnum}.${a.scopeKey}`;
|
|
191
|
+
const gate = (indent, level) => {
|
|
192
|
+
if (!tenanted) {
|
|
193
|
+
const call = `await this.authService.validateAccess(${scopeRef})`;
|
|
194
|
+
return `${indent}const { user } = ${call};`.length <= 80
|
|
195
|
+
? call
|
|
196
|
+
: `await this.authService.validateAccess(\n${indent} ${scopeRef},\n${indent})`;
|
|
197
|
+
}
|
|
198
|
+
const roleRef = `${roleEnum}.${a.roles[level].key}`;
|
|
199
|
+
const call = `await this.authService.validateAccess(${scopeRef}, ${roleRef})`;
|
|
200
|
+
return `${indent}const { user } = ${call};`.length <= 80
|
|
201
|
+
? call
|
|
202
|
+
: `await this.authService.validateAccess(\n${indent} ${scopeRef},\n${indent} ${roleRef},\n${indent})`;
|
|
203
|
+
};
|
|
174
204
|
|
|
175
205
|
const methods = [
|
|
176
206
|
` @Get(':id')
|
|
177
207
|
@ApiOperation({ summary: 'One ${n.kebab} by id.' })
|
|
178
208
|
async get${n.pascal}(@Param('id', ParseUUIDPipe) id: string) {
|
|
179
|
-
const { user } = ${gate(' ')};
|
|
209
|
+
const { user } = ${gate(' ', 'reader')};
|
|
180
210
|
|
|
181
211
|
return this.${svcProp}.get(user, id);
|
|
182
212
|
}`,
|
|
@@ -186,7 +216,7 @@ function controller(base, { processor = false, listProcessor = false } = {}) {
|
|
|
186
216
|
methods.push(` @Post()
|
|
187
217
|
@ApiOperation({ summary: 'Create a ${n.kebab}, or update it when an id is sent.' })
|
|
188
218
|
async upsert${n.pascal}(@Body() body: ${n.dtoClass}) {
|
|
189
|
-
const { user } = ${gate(' ')};
|
|
219
|
+
const { user } = ${gate(' ', 'manager')};
|
|
190
220
|
|
|
191
221
|
return this.${svcProp}.upsert(user, body);
|
|
192
222
|
}`);
|
|
@@ -196,19 +226,22 @@ function controller(base, { processor = false, listProcessor = false } = {}) {
|
|
|
196
226
|
methods.push(` @Post('list')
|
|
197
227
|
@ApiOperation({ summary: 'List, search and filter ${n.kebab}.' })
|
|
198
228
|
async list(@Body() body: CommonListFilterDto) {
|
|
199
|
-
const { user } = ${gate(' ')};
|
|
229
|
+
const { user } = ${gate(' ', 'reader')};
|
|
200
230
|
|
|
201
231
|
return this.${svcProp}.list(user, body);
|
|
202
232
|
}`);
|
|
203
233
|
}
|
|
204
234
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
235
|
+
methods.push(` @Delete(':id')
|
|
236
|
+
@ApiOperation({ summary: 'Delete one ${n.kebab} by id.' })
|
|
237
|
+
async remove${n.pascal}(@Param('id', ParseUUIDPipe) id: string) {
|
|
238
|
+
const { user } = ${gate(' ', 'admin')};
|
|
239
|
+
|
|
240
|
+
return this.${svcProp}.remove(user, id);
|
|
241
|
+
}`);
|
|
208
242
|
|
|
209
243
|
return `${imports.join('\n')}
|
|
210
244
|
|
|
211
|
-
${gateTodo}
|
|
212
245
|
@ApiTags('${n.kebab}')
|
|
213
246
|
@ApiBearerAuth()
|
|
214
247
|
@UsePipes(StrictValidationPipe)
|
|
@@ -283,6 +316,14 @@ function service(base, { processor = false, listProcessor = false } = {}) {
|
|
|
283
316
|
return ${n.camel};
|
|
284
317
|
}`);
|
|
285
318
|
|
|
319
|
+
// Soft delete, through the same ownership-scoped lookup as get().
|
|
320
|
+
members.push(` async remove(user: UserEntity, id: string) {
|
|
321
|
+
const ${n.camel} = await this.get(user, id);
|
|
322
|
+
await ${n.entityClass}.softRemove(${n.camel});
|
|
323
|
+
|
|
324
|
+
return { id, deleted: true, deleted_by: user.id };
|
|
325
|
+
}`);
|
|
326
|
+
|
|
286
327
|
if (processor) {
|
|
287
328
|
// Insert or update: CommonDataDto carries an optional `id`, and the data
|
|
288
329
|
// processor decides which of the two the payload asks for.
|
|
@@ -777,6 +818,33 @@ export const EntityMaps = {
|
|
|
777
818
|
`;
|
|
778
819
|
}
|
|
779
820
|
|
|
821
|
+
/** Fresh src/global/enums/scope.enum.ts (first entry included). */
|
|
822
|
+
function scopeEnumFile(base) {
|
|
823
|
+
const a = accessNames(base);
|
|
824
|
+
return `/** Scope identifiers (sys_scopes.identifier) the app checks. */
|
|
825
|
+
export enum ScopeEnum {
|
|
826
|
+
${a.scopeKey} = '${a.scopeValue}',
|
|
827
|
+
}
|
|
828
|
+
`;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
/** Fresh src/global/enums/role.enum.ts (the entity's default roles included). */
|
|
832
|
+
function roleEnumFile(base) {
|
|
833
|
+
const a = accessNames(base);
|
|
834
|
+
const members = Object.values(a.roles)
|
|
835
|
+
.map((role) => ` ${role.key} = '${role.value}',`)
|
|
836
|
+
.join('\n');
|
|
837
|
+
return `/**
|
|
838
|
+
* Tenant role identifiers (sys_roles.identifier) the app checks. Each scope's
|
|
839
|
+
* defaults rank admin (priority 1) > manager (2) > reader (3) — create the
|
|
840
|
+
* catalogue rows with those priorities under the matching scope.
|
|
841
|
+
*/
|
|
842
|
+
export enum RoleEnum {
|
|
843
|
+
${members}
|
|
844
|
+
}
|
|
845
|
+
`;
|
|
846
|
+
}
|
|
847
|
+
|
|
780
848
|
/** A freshly scaffolded module before any es6.classes content exists. */
|
|
781
849
|
function emptyEs6Classes() {
|
|
782
850
|
return `export const es6Classes = {
|
|
@@ -817,6 +885,8 @@ export class ${pascal}Module {}
|
|
|
817
885
|
|
|
818
886
|
module.exports = {
|
|
819
887
|
names,
|
|
888
|
+
scopeEnumFile,
|
|
889
|
+
roleEnumFile,
|
|
820
890
|
entity,
|
|
821
891
|
attributesDto,
|
|
822
892
|
controller,
|