domain-driver 0.4.0 → 0.5.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
@@ -23,12 +23,12 @@ npx domain-driver make:feature <feature>[/<Entity>]
23
23
  Every command reads your `package.json` once and prints the stack it found before writing anything:
24
24
 
25
25
  ```
26
- Stack: next-fullstack (detected)
26
+ Stack: next-fullstack (detected), root: src/app
27
27
  ```
28
28
 
29
29
  | Stack | Detected when | Features live in |
30
30
  |---|---|---|
31
- | `nest` | `@nestjs/core` is a dependency | `src/<feature>` |
31
+ | `nest` | `@nestjs/core` is a dependency | `src/features/<feature>` if `src/features` exists, otherwise `src/<feature>` |
32
32
  | `tanstack-start` | `@tanstack/react-start` is a dependency | `src/routes/<feature>` or `routes/<feature>` |
33
33
  | `next-fullstack` | `next` is a dependency and `app/api`, `src/app/api`, `pages/api`, or `src/pages/api` exists | `app/<feature>` or `src/app/<feature>` |
34
34
  | `next-frontend` | `next` is a dependency, no api directory | `app/<feature>` or `src/app/<feature>` |
@@ -43,6 +43,34 @@ Override detection with `--stack`:
43
43
  domain-driver --stack nest make:feature cat -a
44
44
  ```
45
45
 
46
+ ### Choosing where features live
47
+
48
+ The "Features live in" column is a convention, not a rule. Two overrides take precedence over it, in this order.
49
+
50
+ `--root` wins over everything, and suits one-off scaffolding:
51
+
52
+ ```bash
53
+ domain-driver --root src/modules make:feature billing/Billing -a
54
+ ```
55
+
56
+ For a project you work in daily, set it once in `package.json` instead of retyping the flag on every command:
57
+
58
+ ```json
59
+ {
60
+ "domainDriver": {
61
+ "featureRoot": "src/modules"
62
+ }
63
+ }
64
+ ```
65
+
66
+ Either way the stack line tells you which one won, so you can see where files will land before they land:
67
+
68
+ ```
69
+ Stack: nest (detected), root: src/modules (package.json)
70
+ ```
71
+
72
+ The root must be a relative path inside the project. An absolute path or one containing `..` is rejected rather than quietly normalised.
73
+
46
74
  ---
47
75
 
48
76
  ## What each stack generates
@@ -238,6 +266,49 @@ Every layer directory carries a `-` prefix so TanStack Router excludes it from r
238
266
 
239
267
  ---
240
268
 
269
+ ## Nest module registration
270
+
271
+ On NestJS, generated classes are wired into the module files that have to know about them. `make:feature` adds the feature module to your root module, and `make:controller`, `make:service`, `make:repository` and `make:action` add their classes to `<feature>.module.ts`.
272
+
273
+ ```
274
+ ✅ Registered AssetsModule in src/app.module.ts
275
+ ```
276
+
277
+ The edit is made through the TypeScript compiler API, borrowed from your own project rather than bundled, and applied as a text insertion at AST-computed positions. Your comments, import order and formatting survive untouched — only the two inserted lines are new. Registering the same class twice does nothing.
278
+
279
+ A bespoke action's controller is inserted **before** `Show<Entity>Controller` rather than appended, because Nest matches routes in declaration order and a custom `GET /actives` would otherwise be swallowed by `GET /:id`.
280
+
281
+ ### When it does not edit
282
+
283
+ If the root module cannot be found, TypeScript cannot be resolved, the file has no `@Module` decorator, or the edited result would not parse, nothing is written and the lines are printed for you to paste:
284
+
285
+ ```
286
+ ℹ️ Did not edit src/app.module.ts (no @Module decorator with an object argument was found). Add by hand:
287
+ import { AssetsModule } from './features/assets/assets.module';
288
+ imports: [ ..., AssetsModule ]
289
+ ```
290
+
291
+ The command still succeeds, because the files it generated were still generated.
292
+
293
+ ### Turning it off, and pointing it somewhere else
294
+
295
+ ```bash
296
+ domain-driver --no-auto-register make:feature assets/Asset -a
297
+ ```
298
+
299
+ ```json
300
+ {
301
+ "domainDriver": {
302
+ "autoRegister": false,
303
+ "rootModule": "src/core/root.module.ts"
304
+ }
305
+ }
306
+ ```
307
+
308
+ `autoRegister: false` restores the old behaviour, where nothing but newly generated files is ever written. `rootModule` names the root module for projects where `src/app.module.ts` and `app.module.ts` are both wrong.
309
+
310
+ ---
311
+
241
312
  ## Philosophy
242
313
 
243
314
  Everything for a feature lives in one folder, and every file does one thing.
@@ -277,11 +348,33 @@ The cache lives in `~/.cache/domain-driver` (or `$XDG_CACHE_HOME/domain-driver`)
277
348
 
278
349
  ---
279
350
 
351
+ ## Upgrading from 0.4.x
352
+
353
+ On NestJS, scaffolding now **edits two files it did not create**: your root module and each feature's module. Every earlier version only ever created files. The edit is surgical and verified — see [Nest module registration](#nest-module-registration) for what it does and when it refuses — but if you want the old write-only guarantee back:
354
+
355
+ ```json
356
+ { "domainDriver": { "autoRegister": false } }
357
+ ```
358
+
359
+ Nothing changes for any other stack.
360
+
361
+ ---
362
+
363
+ ## Upgrading from 0.4.0
364
+
365
+ Two changes affect existing projects.
366
+
367
+ On NestJS, a project that already has a `src/features` directory now scaffolds into it instead of directly into `src`. If you keep features in `src` you are unaffected, since the behaviour is opt-in by that directory existing. To pin either choice explicitly, set `domainDriver.featureRoot` in `package.json`.
368
+
369
+ The entity half of a `<feature>/<Entity>` target must now be PascalCase. `make:schema users/user` previously produced `Listuser.service.ts` exporting `ListuserService`; it now fails with a message telling you to pass `User`.
370
+
371
+ ---
372
+
280
373
  ## Upgrading from 0.3.x
281
374
 
282
375
  Hooks are now one file per action — `<Action><Entity>.hook.ts` exporting `use<Action><Entity>` (`useListCat`, `useCreateCat`, ...) — instead of a single combined `use<Entity>.ts`, which is no longer generated. `make:hook` now takes `<feature>/<Entity>`, not `<feature>/use<Entity>`. `make:action` writes a matching hook alongside the service and repository on any stack that has a hook layer.
283
376
 
284
- domain-driver never overwrites a file that already exists, so this only changes new scaffolding: a `use<Entity>.ts` written by an older version is left alone and keeps working. New features and new actions get the per-action hooks; wire them together in the container, since they no longer share state:
377
+ domain-driver never overwrites a generated file that already exists, so this only changes new scaffolding: a `use<Entity>.ts` written by an older version is left alone and keeps working. New features and new actions get the per-action hooks; wire them together in the container, since they no longer share state:
285
378
 
286
379
  ```tsx
287
380
  const { data, loading, error, refetch } = useListCat();
package/dist/cli.js CHANGED
@@ -43,7 +43,8 @@ const component_1 = require("./commands/component");
43
43
  const container_1 = require("./commands/container");
44
44
  const controller_1 = require("./commands/controller");
45
45
  const feature_1 = require("./commands/feature");
46
- const hints_1 = require("./commands/hints");
46
+ const register_1 = require("./commands/register");
47
+ const resolve_1 = require("./commands/resolve");
47
48
  const hook_1 = require("./commands/hook");
48
49
  const repository_1 = require("./commands/repository");
49
50
  const schema_1 = require("./commands/schema");
@@ -78,7 +79,9 @@ function createProgram(deps) {
78
79
  .name('domain-driver')
79
80
  .description('CLI scaffolding tool for domain-driven feature folders in Next.js, React, Node, and NestJS projects')
80
81
  .version(deps.current)
81
- .option('--stack <name>', `Override stack detection (${types_2.STACK_NAMES.join(', ')})`);
82
+ .option('--stack <name>', `Override stack detection (${types_2.STACK_NAMES.join(', ')})`)
83
+ .option('--root <dir>', 'Override where feature folders are created, for example src/features')
84
+ .option('--no-auto-register', 'Do not edit Nest module files; print what to add instead');
82
85
  let pendingNotice = Promise.resolve(null);
83
86
  program.hook('preAction', (_thisCommand, actionCommand) => {
84
87
  const name = actionCommand.name();
@@ -93,8 +96,12 @@ function createProgram(deps) {
93
96
  }
94
97
  if (SKIP_DETECTION.has(name))
95
98
  return;
96
- const { stack } = program.opts();
97
- deps.log((0, detect_1.describeStack)((0, detect_1.detectStack)(stack)));
99
+ const { stack, root } = program.opts();
100
+ // commander defaults a --no-x flag to true, which would shadow the package.json key,
101
+ // so only an explicitly passed flag counts as an override.
102
+ const passed = program.getOptionValueSource('autoRegister') === 'cli';
103
+ const autoRegister = passed ? program.opts().autoRegister : undefined;
104
+ deps.log((0, detect_1.describeStack)((0, detect_1.detectStack)({ stack, root, autoRegister })));
98
105
  });
99
106
  program.hook('postAction', async () => {
100
107
  const notice = await pendingNotice;
@@ -139,7 +146,7 @@ function createProgram(deps) {
139
146
  const { feature, name } = (0, target_1.parseTarget)(target);
140
147
  const wrote = (0, service_1.makeService)(feature, name, (0, sides_1.parseSide)(options.side));
141
148
  if (wrote)
142
- (0, hints_1.hintRegisterInModule)(feature, (0, hints_1.standardClassNames)(name, 'Service'));
149
+ (0, register_1.registerClasses)((0, resolve_1.requireFeature)(feature), 'service', (0, register_1.standardActionNames)(name));
143
150
  });
144
151
  program
145
152
  .command('make:repository <target>')
@@ -149,7 +156,7 @@ function createProgram(deps) {
149
156
  const { feature, name } = (0, target_1.parseTarget)(target);
150
157
  const wrote = (0, repository_1.makeRepository)(feature, name, (0, sides_1.parseSide)(options.side));
151
158
  if (wrote)
152
- (0, hints_1.hintRegisterInModule)(feature, (0, hints_1.standardClassNames)(name, 'Repository'));
159
+ (0, register_1.registerClasses)((0, resolve_1.requireFeature)(feature), 'repository', (0, register_1.standardActionNames)(name));
153
160
  });
154
161
  program
155
162
  .command('make:controller <target>')
@@ -158,7 +165,7 @@ function createProgram(deps) {
158
165
  const { feature, name } = (0, target_1.parseTarget)(target);
159
166
  const wrote = (0, controller_1.makeController)(feature, name);
160
167
  if (wrote)
161
- (0, hints_1.hintRegisterInModule)(feature, (0, hints_1.standardClassNames)(name, 'Controller'));
168
+ (0, register_1.registerClasses)((0, resolve_1.requireFeature)(feature), 'controller', (0, register_1.standardActionNames)(name));
162
169
  });
163
170
  program
164
171
  .command('make:action <target> <action>')
@@ -171,7 +178,7 @@ function createProgram(deps) {
171
178
  const wrote = (0, action_1.makeAction)(feature, name, action, { withInput: options.withInput, returns });
172
179
  if (wrote) {
173
180
  const { pascal } = (0, actions_1.actionCase)(action);
174
- (0, hints_1.hintRegisterInModule)(feature, [`${pascal}Controller`, `${pascal}Service`, `${pascal}Repository`], `list ${pascal}Controller before Show${name}Controller in controllers`);
181
+ (0, register_1.registerCustomAction)((0, resolve_1.requireFeature)(feature), pascal, name);
175
182
  }
176
183
  });
177
184
  program
@@ -46,6 +46,7 @@ const container_1 = require("./container");
46
46
  const controller_1 = require("./controller");
47
47
  const hook_1 = require("./hook");
48
48
  const repository_1 = require("./repository");
49
+ const register_1 = require("./register");
49
50
  const resolve_1 = require("./resolve");
50
51
  const schema_1 = require("./schema");
51
52
  const service_1 = require("./service");
@@ -61,6 +62,7 @@ async function makeFeature(name, all = false, entityName) {
61
62
  if (all)
62
63
  scaffoldLayers(ctx, entity);
63
64
  writeEntryFile(ctx, entity, all);
65
+ (0, register_1.registerFeatureModule)(ctx, entity);
64
66
  if (all)
65
67
  console.log(`✅ All files scaffolded for "${name}"`);
66
68
  }
@@ -3,10 +3,6 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.resetHints = resetHints;
4
4
  exports.hintNestjsZod = hintNestjsZod;
5
5
  exports.hintReactQuery = hintReactQuery;
6
- exports.standardClassNames = standardClassNames;
7
- exports.hintRegisterInModule = hintRegisterInModule;
8
- const detect_1 = require("../stack/detect");
9
- const actions_1 = require("../templates/actions");
10
6
  let nestjsZodHinted = false;
11
7
  let reactQueryHinted = false;
12
8
  function resetHints() {
@@ -27,12 +23,3 @@ function hintReactQuery(profile) {
27
23
  console.log('ℹ️ Hooks use TanStack Query. Install it: npm install @tanstack/react-query');
28
24
  console.log(' Then wrap your app in a QueryClientProvider.');
29
25
  }
30
- function standardClassNames(name, suffix) {
31
- return actions_1.ACTIONS.map((action) => `${action}${name}${suffix}`);
32
- }
33
- function hintRegisterInModule(feature, classNames, note) {
34
- if ((0, detect_1.detectStack)().stack !== 'nest')
35
- return;
36
- const suffix = note !== undefined ? ` (${note})` : '';
37
- console.log(`ℹ️ Register ${classNames.join(', ')} in ${feature}.module.ts${suffix}`);
38
- }
@@ -0,0 +1,105 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.KINDS = void 0;
37
+ exports.registerClasses = registerClasses;
38
+ exports.standardActionNames = standardActionNames;
39
+ exports.registerCustomAction = registerCustomAction;
40
+ exports.registerFeatureModule = registerFeatureModule;
41
+ const path = __importStar(require("path"));
42
+ const registration_1 = require("../nest/registration");
43
+ const root_module_1 = require("../nest/root-module");
44
+ const registry_1 = require("../stack/registry");
45
+ const actions_1 = require("../templates/actions");
46
+ const fs_1 = require("../utils/fs");
47
+ exports.KINDS = Object.freeze({
48
+ controller: { layer: 'controller', suffix: 'Controller', fileSuffix: 'controller', property: 'controllers' },
49
+ service: { layer: 'serverService', suffix: 'Service', fileSuffix: 'service', property: 'providers' },
50
+ repository: { layer: 'serverRepository', suffix: 'Repository', fileSuffix: 'repository', property: 'providers' },
51
+ });
52
+ function featureModule(ctx) {
53
+ return path.join(ctx.featureDir, `${ctx.feature}.module.ts`);
54
+ }
55
+ function entriesFor(ctx, kind, names) {
56
+ const dir = path.join(ctx.featureDir, (0, registry_1.layerDir)(ctx.profile, kind.layer));
57
+ return names
58
+ .map((name) => ({
59
+ identifier: `${name}${kind.suffix}`,
60
+ definedIn: path.join(dir, `${name}.${kind.fileSuffix}.ts`),
61
+ property: kind.property,
62
+ }))
63
+ .filter((entry) => (0, fs_1.fileExists)(entry.definedIn));
64
+ }
65
+ /** Registers generated classes in the feature's own module. Nest is the only stack with one. */
66
+ function registerClasses(ctx, kind, actionNames) {
67
+ if (ctx.profile.name !== 'nest')
68
+ return;
69
+ (0, registration_1.applyRegistrations)(featureModule(ctx), entriesFor(ctx, exports.KINDS[kind], actionNames), ctx.stack.autoRegister);
70
+ }
71
+ function standardActionNames(entity) {
72
+ return actions_1.ACTIONS.map((action) => `${action}${entity}`);
73
+ }
74
+ /**
75
+ * A bespoke action's controller must be declared ahead of Show<Entity>Controller: Nest matches
76
+ * routes in declaration order, and a custom GET path would otherwise be swallowed by `/:id`.
77
+ */
78
+ function registerCustomAction(ctx, action, entity) {
79
+ if (ctx.profile.name !== 'nest')
80
+ return;
81
+ const entries = [
82
+ { ...entriesFor(ctx, exports.KINDS.controller, [action])[0], before: `Show${entity}Controller` },
83
+ entriesFor(ctx, exports.KINDS.service, [action])[0],
84
+ entriesFor(ctx, exports.KINDS.repository, [action])[0],
85
+ ];
86
+ (0, registration_1.applyRegistrations)(featureModule(ctx), entries, ctx.stack.autoRegister);
87
+ }
88
+ /** Registers the feature's module in the application's root module. */
89
+ function registerFeatureModule(ctx, entity) {
90
+ if (ctx.profile.name !== 'nest')
91
+ return;
92
+ const moduleFile = featureModule(ctx);
93
+ if (!(0, fs_1.fileExists)(moduleFile))
94
+ return;
95
+ const rootModule = (0, root_module_1.findRootModule)(process.cwd(), ctx.stack.featureRoot, ctx.stack.rootModule);
96
+ if (rootModule === null) {
97
+ if (!ctx.stack.autoRegister)
98
+ return;
99
+ console.log(`ℹ️ No root module found, so ${entity}Module was not registered. Add it by hand, or name the file with "domainDriver": { "rootModule": "..." } in package.json.`);
100
+ return;
101
+ }
102
+ if (path.resolve(rootModule) === path.resolve(moduleFile))
103
+ return;
104
+ (0, registration_1.applyRegistrations)(rootModule, [{ identifier: `${entity}Module`, definedIn: moduleFile, property: 'imports' }], ctx.stack.autoRegister);
105
+ }
@@ -3,11 +3,15 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.parseTarget = parseTarget;
4
4
  exports.parseFeatureTarget = parseFeatureTarget;
5
5
  const naming_1 = require("../utils/naming");
6
- const NAME_PATTERN = /^[A-Za-z][A-Za-z0-9]*$/;
6
+ const NAME_PATTERN = /^[A-Z][A-Za-z0-9]*$/;
7
+ const HOOK_NAME_PATTERN = /^use[A-Z]/;
7
8
  function validateName(name) {
8
- if (!NAME_PATTERN.test(name)) {
9
- throw new Error(`Name "${name}" must be letters and digits only, for example User.`);
9
+ if (NAME_PATTERN.test(name))
10
+ return;
11
+ if (HOOK_NAME_PATTERN.test(name)) {
12
+ throw new Error(`Name "${name}" is a hook name. Pass the entity instead, for example ${name.slice(3)}.`);
10
13
  }
14
+ throw new Error(`Name "${name}" must be PascalCase, for example User.`);
11
15
  }
12
16
  function parseTarget(value) {
13
17
  const parts = value.split('/');
@@ -35,7 +35,9 @@ const SKILL_LINES = [
35
35
  '',
36
36
  '## Detect the stack',
37
37
  '',
38
- 'The tool reads `package.json` and prints `Stack: <stack> (detected)` before every command. Stacks: `next-fullstack`, `next-frontend`, `react`, `node` (Express, Fastify, Hono, or none), `nest`, `tanstack-start`. Override with `--stack <name>` if detection is wrong.',
38
+ 'The tool reads `package.json` and prints `Stack: <stack> (detected), root: <dir>` before every command. Stacks: `next-fullstack`, `next-frontend`, `react`, `node` (Express, Fastify, Hono, or none), `nest`, `tanstack-start`. Override with `--stack <name>` if detection is wrong.',
39
+ 'Where features are written can be overridden too: `--root <dir>` for one command, or a `domainDriver.featureRoot` key in `package.json` for the project. Read the root off the stack line rather than assuming the convention.',
40
+ 'On Nest the tool registers what it generates: the feature module in the root module, and controllers, services and repositories in `<feature>.module.ts`. Do not add those by hand — it prints what it registered, and prints the lines to paste when it cannot. A bespoke action controller is placed before `Show<Entity>Controller` so its route is not swallowed by `/:id`.',
39
41
  '',
40
42
  '## Commands',
41
43
  '',
@@ -70,7 +72,7 @@ const SKILL_LINES = [
70
72
  '',
71
73
  '- Next.js: `app/<feature>` or `src/app/<feature>`; route handlers under `app/api/<feature>`.',
72
74
  '- React and Node: `src/features/<feature>` or `features/<feature>`.',
73
- '- Nest: `src/<feature>` with a `<feature>.module.ts`.',
75
+ '- Nest: `src/features/<feature>` when `src/features` exists, otherwise `src/<feature>`, with a `<feature>.module.ts`.',
74
76
  '- TanStack Start: `src/routes/<feature>` or `routes/<feature>`; every layer folder is prefixed with `-` so the router ignores it, and the entry file is `index.tsx`, not `page.tsx`.',
75
77
  ];
76
78
  exports.AGENTS_SECTION = AGENTS_LINES.join('\n');
@@ -0,0 +1,135 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.registerInModule = registerInModule;
4
+ function bail(reason) {
5
+ return { status: 'bailed', reason };
6
+ }
7
+ function parse(ts, source) {
8
+ return ts.createSourceFile('module.ts', source, ts.ScriptTarget.Latest, true);
9
+ }
10
+ function moduleArgument(ts, file) {
11
+ for (const statement of file.statements) {
12
+ if (!ts.isClassDeclaration(statement) || !ts.canHaveDecorators(statement))
13
+ continue;
14
+ for (const decorator of ts.getDecorators(statement) ?? []) {
15
+ const call = decorator.expression;
16
+ if (!ts.isCallExpression(call))
17
+ continue;
18
+ if (!ts.isIdentifier(call.expression) || call.expression.text !== 'Module')
19
+ continue;
20
+ const [argument] = call.arguments;
21
+ if (argument !== undefined && ts.isObjectLiteralExpression(argument))
22
+ return argument;
23
+ }
24
+ }
25
+ return null;
26
+ }
27
+ function propertyArray(ts, object, name) {
28
+ for (const property of object.properties) {
29
+ if (!ts.isPropertyAssignment(property))
30
+ continue;
31
+ const key = property.name;
32
+ const text = ts.isIdentifier(key) || ts.isStringLiteral(key) ? key.text : null;
33
+ if (text !== name)
34
+ continue;
35
+ return ts.isArrayLiteralExpression(property.initializer) ? property.initializer : 'not-array';
36
+ }
37
+ return 'missing';
38
+ }
39
+ function contains(ts, array, identifier) {
40
+ return array.elements.some((element) => ts.isIdentifier(element) && element.text === identifier);
41
+ }
42
+ function newlineOf(source) {
43
+ return source.includes('\r\n') ? '\r\n' : '\n';
44
+ }
45
+ function alreadyImported(ts, file, identifier) {
46
+ return file.statements.filter(ts.isImportDeclaration).some((declaration) => {
47
+ const bindings = declaration.importClause?.namedBindings;
48
+ if (bindings === undefined || !ts.isNamedImports(bindings))
49
+ return false;
50
+ return bindings.elements.some((element) => element.name.text === identifier);
51
+ });
52
+ }
53
+ function indentAt(source, position) {
54
+ const lineStart = source.lastIndexOf('\n', position - 1) + 1;
55
+ return /^[ \t]*/.exec(source.slice(lineStart, position))?.[0] ?? '';
56
+ }
57
+ function importEdit(ts, source, file, request) {
58
+ // A second `import { X }` for an identifier already bound is a duplicate declaration, which
59
+ // is a type error rather than a syntax error, so nothing downstream would catch it.
60
+ if (alreadyImported(ts, file, request.identifier))
61
+ return null;
62
+ const eol = newlineOf(source);
63
+ const line = `import { ${request.identifier} } from '${request.importPath}';`;
64
+ const imports = file.statements.filter(ts.isImportDeclaration);
65
+ if (imports.length === 0)
66
+ return { at: 0, text: `${line}${eol}` };
67
+ return { at: imports[imports.length - 1].end, text: `${eol}${line}` };
68
+ }
69
+ function elementEdit(ts, source, file, array, request) {
70
+ const identifier = request.identifier;
71
+ const eol = newlineOf(source);
72
+ if (array.elements.length === 0) {
73
+ const outer = indentAt(source, array.getStart(file));
74
+ return { at: array.end - 1, text: `${eol}${outer} ${identifier},${eol}${outer}` };
75
+ }
76
+ const anchor = request.before === undefined
77
+ ? undefined
78
+ : array.elements.find((element) => ts.isIdentifier(element) && element.text === request.before);
79
+ const last = array.elements[array.elements.length - 1];
80
+ const singleLine = !source.slice(array.getStart(file), last.end).includes('\n');
81
+ if (anchor !== undefined) {
82
+ const at = anchor.getStart(file);
83
+ return { at, text: singleLine ? `${identifier}, ` : `${identifier},${eol}${indentAt(source, at)}` };
84
+ }
85
+ const text = singleLine ? `, ${identifier}` : `,${eol}${indentAt(source, last.getStart(file))}${identifier}`;
86
+ return { at: last.end, text };
87
+ }
88
+ function apply(source, edits) {
89
+ return [...edits]
90
+ .sort((a, b) => b.at - a.at)
91
+ .reduce((text, edit) => `${text.slice(0, edit.at)}${edit.text}${text.slice(edit.at)}`, source);
92
+ }
93
+ /**
94
+ * The edit is only returned if the result both parses cleanly and reads back as registered.
95
+ * A wrong offset produces a file that fails one of those, and the caller keeps the original.
96
+ */
97
+ function parses(ts, source) {
98
+ const diagnostics = ts.transpileModule(source, {
99
+ reportDiagnostics: true,
100
+ compilerOptions: { target: ts.ScriptTarget.Latest },
101
+ }).diagnostics;
102
+ return (diagnostics ?? []).length === 0;
103
+ }
104
+ function verify(ts, source, request) {
105
+ if (!parses(ts, source))
106
+ return false;
107
+ const file = parse(ts, source);
108
+ const object = moduleArgument(ts, file);
109
+ if (object === null)
110
+ return false;
111
+ const array = propertyArray(ts, object, request.property);
112
+ if (array === 'missing' || array === 'not-array')
113
+ return false;
114
+ return contains(ts, array, request.identifier);
115
+ }
116
+ function registerInModule(ts, source, request) {
117
+ if (!parses(ts, source))
118
+ return bail('the file does not parse');
119
+ const file = parse(ts, source);
120
+ const object = moduleArgument(ts, file);
121
+ if (object === null)
122
+ return bail('no @Module decorator with an object argument was found');
123
+ const array = propertyArray(ts, object, request.property);
124
+ if (array === 'missing')
125
+ return bail(`the @Module decorator has no "${request.property}" property`);
126
+ if (array === 'not-array')
127
+ return bail(`"${request.property}" is not an array literal`);
128
+ if (contains(ts, array, request.identifier))
129
+ return { status: 'already-registered' };
130
+ const edits = [importEdit(ts, source, file, request), elementEdit(ts, source, file, array, request)];
131
+ const edited = apply(source, edits.filter((edit) => edit !== null));
132
+ if (!verify(ts, edited, request))
133
+ return bail('the edited file did not read back as valid');
134
+ return { status: 'edited', source: edited };
135
+ }
@@ -0,0 +1,129 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.applyRegistrations = applyRegistrations;
37
+ const fs = __importStar(require("fs"));
38
+ const path = __importStar(require("path"));
39
+ const imports_1 = require("../utils/imports");
40
+ const register_1 = require("./register");
41
+ const typescript_1 = require("./typescript");
42
+ /**
43
+ * Edits one module file in place, or explains what to add by hand and changes nothing.
44
+ * A bail is never a failure: the generated files exist either way, so the caller succeeds.
45
+ */
46
+ function applyRegistrations(moduleFile, entries, enabled) {
47
+ if (entries.length === 0)
48
+ return;
49
+ if (!enabled)
50
+ return explain(moduleFile, entries, 'automatic registration is off');
51
+ if (!fs.existsSync(moduleFile))
52
+ return explain(moduleFile, entries, 'the module file does not exist');
53
+ const ts = (0, typescript_1.loadTypeScript)(process.cwd());
54
+ if (ts === null)
55
+ return explain(moduleFile, entries, 'typescript could not be resolved from this project');
56
+ let source;
57
+ try {
58
+ source = fs.readFileSync(moduleFile, 'utf-8');
59
+ }
60
+ catch {
61
+ return explain(moduleFile, entries, 'the file could not be read');
62
+ }
63
+ const added = [];
64
+ const outstanding = [];
65
+ for (const entry of entries) {
66
+ const result = (0, register_1.registerInModule)(ts, source, {
67
+ identifier: entry.identifier,
68
+ importPath: (0, imports_1.resolveImport)(moduleFile, entry.definedIn),
69
+ property: entry.property,
70
+ before: entry.before,
71
+ });
72
+ if (result.status === 'bailed') {
73
+ return explain(moduleFile, [...outstanding, ...entries.slice(entries.indexOf(entry))], result.reason);
74
+ }
75
+ if (result.status === 'already-registered')
76
+ continue;
77
+ source = result.source;
78
+ added.push(entry.identifier);
79
+ }
80
+ if (added.length === 0)
81
+ return;
82
+ if (!write(moduleFile, source))
83
+ return explain(moduleFile, entries, 'the file could not be written');
84
+ console.log(`✅ Registered ${added.join(', ')} in ${relative(moduleFile)}`);
85
+ }
86
+ /**
87
+ * Writes through a sibling temp file and renames. `writeFileSync` truncates before writing, so a
88
+ * failure partway through would leave the file that boots the application cut in half.
89
+ * A same-directory rename is atomic, which makes that outcome unreachable.
90
+ */
91
+ function write(moduleFile, source) {
92
+ const temporary = `${moduleFile}.domain-driver.tmp`;
93
+ try {
94
+ fs.writeFileSync(temporary, source);
95
+ fs.renameSync(temporary, moduleFile);
96
+ return true;
97
+ }
98
+ catch {
99
+ try {
100
+ fs.rmSync(temporary, { force: true });
101
+ }
102
+ catch {
103
+ // The temp file is already gone, or unremovable for the same reason the write failed.
104
+ }
105
+ return false;
106
+ }
107
+ }
108
+ function explain(moduleFile, entries, reason) {
109
+ if (entries.length === 0)
110
+ return;
111
+ console.log(`ℹ️ Did not edit ${relative(moduleFile)} (${reason}). Add by hand:`);
112
+ for (const entry of entries) {
113
+ console.log(` import { ${entry.identifier} } from '${(0, imports_1.resolveImport)(moduleFile, entry.definedIn)}';`);
114
+ }
115
+ const byProperty = new Map();
116
+ for (const entry of entries) {
117
+ byProperty.set(entry.property, [...(byProperty.get(entry.property) ?? []), entry]);
118
+ }
119
+ for (const [property, group] of byProperty) {
120
+ console.log(` ${property}: [ ..., ${group.map(placement).join(', ')} ]`);
121
+ }
122
+ }
123
+ /** An ordered entry has to say where it goes, or the pasted line reproduces the bug it prevents. */
124
+ function placement(entry) {
125
+ return entry.before === undefined ? entry.identifier : `${entry.identifier}, ${entry.before}`;
126
+ }
127
+ function relative(target) {
128
+ return path.relative(process.cwd(), target) || target;
129
+ }
@@ -0,0 +1,57 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.findRootModule = findRootModule;
37
+ const path = __importStar(require("path"));
38
+ const fs_1 = require("../utils/fs");
39
+ /**
40
+ * Probes the conventional locations for a Nest root module. A project that keeps it
41
+ * anywhere else names it with the `domainDriver.rootModule` key instead of being guessed at.
42
+ */
43
+ function findRootModule(cwd, featureRoot, configured) {
44
+ if (configured !== null) {
45
+ const target = path.join(cwd, configured);
46
+ return (0, fs_1.fileExists)(target) ? target : null;
47
+ }
48
+ // Most specific first: a monorepo's stray root-level src/app.module.ts must not win over
49
+ // the one sitting beside the feature root it actually belongs to.
50
+ const candidates = [path.join(path.dirname(featureRoot), 'app.module.ts'), 'src/app.module.ts', 'app.module.ts'];
51
+ for (const candidate of candidates) {
52
+ const target = path.join(cwd, candidate);
53
+ if ((0, fs_1.fileExists)(target))
54
+ return target;
55
+ }
56
+ return null;
57
+ }
@@ -0,0 +1,27 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.loadTypeScript = loadTypeScript;
4
+ /**
5
+ * Borrows the consuming project's own TypeScript rather than shipping one. Every TypeScript
6
+ * Nest project has it — it cannot compile without one — so this keeps a scaffolding CLI free
7
+ * of a heavy runtime dependency. Returning null is a normal outcome, not an error: the caller
8
+ * falls back to printing the lines for the user to paste.
9
+ */
10
+ function loadTypeScript(cwd, resolve = require.resolve, load = require) {
11
+ try {
12
+ const candidate = load(resolve('typescript', { paths: [cwd] }));
13
+ return isTypeScript(candidate) ? candidate : null;
14
+ }
15
+ catch {
16
+ return null;
17
+ }
18
+ }
19
+ function isTypeScript(candidate) {
20
+ if (typeof candidate !== 'object' || candidate === null)
21
+ return false;
22
+ const api = candidate;
23
+ return (typeof api.createSourceFile === 'function' &&
24
+ typeof api.transpileModule === 'function' &&
25
+ typeof api.getDecorators === 'function' &&
26
+ typeof api.canHaveDecorators === 'function');
27
+ }
@@ -41,41 +41,53 @@ const path = __importStar(require("path"));
41
41
  const fs_1 = require("../utils/fs");
42
42
  const types_1 = require("./types");
43
43
  const API_DIRS = ['app/api', 'src/app/api', 'pages/api', 'src/pages/api'];
44
+ const ROOT_LABELS = Object.freeze({
45
+ detected: '',
46
+ flag: ' (--root)',
47
+ config: ' (package.json)',
48
+ });
44
49
  let cached;
45
50
  function resetStackCache() {
46
51
  cached = undefined;
47
52
  }
48
- function detectStack(override) {
53
+ function detectStack(options = {}) {
49
54
  if (cached)
50
55
  return cached;
51
56
  const cwd = process.cwd();
57
+ const override = options.stack;
52
58
  const overridden = override !== undefined;
53
- const deps = readDependencies(cwd, overridden);
59
+ const pkg = readPackageJson(cwd, overridden);
60
+ const deps = dependencyNames(pkg);
54
61
  const stack = overridden ? parseOverride(override) : inferStack(cwd, deps);
62
+ const root = resolveRoot(cwd, stack, pkg, options.root);
55
63
  const result = Object.freeze({
56
64
  stack,
57
65
  source: overridden ? 'override' : 'detected',
58
66
  httpFramework: stack === 'node' ? detectHttpFramework(deps) : null,
59
- featureRoot: resolveFeatureRoot(cwd, stack),
67
+ featureRoot: root.featureRoot,
68
+ featureRootSource: root.source,
60
69
  hasNestjsZod: deps.has('nestjs-zod'),
70
+ rootModule: configuredRootModule(pkg),
71
+ autoRegister: options.autoRegister ?? configuredAutoRegister(pkg),
61
72
  });
62
73
  cached = result;
63
74
  return result;
64
75
  }
65
76
  function describeStack(stack) {
66
77
  const base = `Stack: ${stack.stack} (${stack.source})`;
67
- if (stack.stack !== 'node')
68
- return base;
69
- return `${base}, http: ${stack.httpFramework ?? 'none'}`;
78
+ const http = stack.stack === 'node' ? `, http: ${stack.httpFramework ?? 'none'}` : '';
79
+ return `${base}${http}, root: ${stack.featureRoot}${ROOT_LABELS[stack.featureRootSource]}`;
70
80
  }
71
- function readDependencies(cwd, optional) {
81
+ function readPackageJson(cwd, optional) {
72
82
  const pkgPath = path.join(cwd, 'package.json');
73
83
  if (!fs.existsSync(pkgPath)) {
74
84
  if (optional)
75
- return new Set();
85
+ return {};
76
86
  throw new Error(`No package.json found in ${cwd}. Run domain-driver from your project root, or pass --stack <name>.`);
77
87
  }
78
- const pkg = parsePackageJson(pkgPath);
88
+ return parsePackageJson(pkgPath);
89
+ }
90
+ function dependencyNames(pkg) {
79
91
  return new Set([
80
92
  ...Object.keys(pkg.dependencies ?? {}),
81
93
  ...Object.keys(pkg.devDependencies ?? {}),
@@ -113,7 +125,56 @@ function hasApiDir(cwd) {
113
125
  function detectHttpFramework(deps) {
114
126
  return types_1.HTTP_FRAMEWORKS.find((name) => deps.has(name)) ?? null;
115
127
  }
116
- function resolveFeatureRoot(cwd, stack) {
128
+ function resolveRoot(cwd, stack, pkg, flag) {
129
+ if (flag !== undefined) {
130
+ return { featureRoot: validateRoot(flag, '--root'), source: 'flag' };
131
+ }
132
+ const configured = configuredRoot(pkg);
133
+ if (configured !== null) {
134
+ return { featureRoot: configured, source: 'config' };
135
+ }
136
+ return { featureRoot: conventionalRoot(cwd, stack), source: 'detected' };
137
+ }
138
+ function configuredRootModule(pkg) {
139
+ const value = pkg.domainDriver?.rootModule;
140
+ if (value === undefined)
141
+ return null;
142
+ if (typeof value !== 'string') {
143
+ throw new Error('package.json "domainDriver.rootModule" must be a string, for example "src/app.module.ts".');
144
+ }
145
+ return validateRoot(value, 'package.json');
146
+ }
147
+ function configuredAutoRegister(pkg) {
148
+ const value = pkg.domainDriver?.autoRegister;
149
+ if (value === undefined)
150
+ return true;
151
+ if (typeof value !== 'boolean') {
152
+ throw new Error('package.json "domainDriver.autoRegister" must be true or false.');
153
+ }
154
+ return value;
155
+ }
156
+ function configuredRoot(pkg) {
157
+ const value = pkg.domainDriver?.featureRoot;
158
+ if (value === undefined)
159
+ return null;
160
+ if (typeof value !== 'string') {
161
+ throw new Error('package.json "domainDriver.featureRoot" must be a string, for example "src/features".');
162
+ }
163
+ return validateRoot(value, 'package.json');
164
+ }
165
+ /**
166
+ * The root becomes a filesystem path under the project, so an absolute path or any
167
+ * `..` segment would write outside it. Both are rejected rather than normalised away.
168
+ */
169
+ function validateRoot(value, source) {
170
+ const normalized = value.replace(/\\/g, '/').replace(/\/+$/, '');
171
+ const escapes = normalized === '' || path.isAbsolute(value) || normalized.split('/').includes('..');
172
+ if (escapes) {
173
+ throw new Error(`Feature root "${value}" from ${source} must be a relative path inside the project, for example src/features.`);
174
+ }
175
+ return normalized;
176
+ }
177
+ function conventionalRoot(cwd, stack) {
117
178
  switch (stack) {
118
179
  case 'next-fullstack':
119
180
  case 'next-frontend':
@@ -122,7 +183,7 @@ function resolveFeatureRoot(cwd, stack) {
122
183
  case 'node':
123
184
  return (0, fs_1.isDirectory)(path.join(cwd, 'src')) ? 'src/features' : 'features';
124
185
  case 'nest':
125
- return 'src';
186
+ return (0, fs_1.isDirectory)(path.join(cwd, 'src', 'features')) ? 'src/features' : 'src';
126
187
  case 'tanstack-start':
127
188
  if ((0, fs_1.isDirectory)(path.join(cwd, 'src', 'routes')))
128
189
  return 'src/routes';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "domain-driver",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "CLI scaffolding tool for domain-driven feature folders in Next.js, React, Node, NestJS, and TanStack Start projects, with per-action files, bespoke actions, and agent guidance",
5
5
  "main": "dist/index.js",
6
6
  "bin": {