tshex-cli 1.0.22 → 1.0.24
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/build/main.js +92 -15
- package/docs/context-ports.md +54 -40
- package/docs/library-structure.md +47 -39
- package/docs/shared/application/data.md +9 -4
- package/docs/shared/application/events.md +7 -5
- package/docs/shared/application/http.md +6 -3
- package/docs/shared/application/loggers.md +6 -3
- package/docs/shared/application/services.md +7 -3
- package/package.json +1 -1
- package/{README.md → readme.md} +77 -18
- package/source/main.ts +115 -15
- package/templates/tests/content.ts +16 -0
package/build/main.js
CHANGED
|
@@ -2,18 +2,20 @@
|
|
|
2
2
|
import { program } from 'commander';
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
import path from 'node:path';
|
|
5
|
+
import readline from 'node:readline/promises';
|
|
6
|
+
import { stdin as input, stdout as output } from 'node:process';
|
|
5
7
|
function readPackageJson() {
|
|
6
8
|
const filePath = path.join(import.meta.dirname, '..', 'package.json');
|
|
7
9
|
const fileContents = fs.readFileSync(filePath, 'utf-8');
|
|
8
10
|
return JSON.parse(fileContents);
|
|
9
11
|
}
|
|
10
|
-
function
|
|
12
|
+
function executeCreateProject(templatesDir, projectDir) {
|
|
11
13
|
try {
|
|
12
|
-
fs.cpSync(path.join(templatesDir, 'lib'),
|
|
14
|
+
fs.cpSync(path.join(templatesDir, 'lib'), projectDir, {
|
|
13
15
|
recursive: true,
|
|
14
16
|
filter: (src) => src.endsWith('.gitkeep') === false
|
|
15
17
|
});
|
|
16
|
-
console.log('
|
|
18
|
+
console.log('Project created successfully');
|
|
17
19
|
}
|
|
18
20
|
catch (err) {
|
|
19
21
|
console.error(err);
|
|
@@ -43,7 +45,53 @@ function executeCreateReactContext(templatesDir, contextDir) {
|
|
|
43
45
|
console.error(err);
|
|
44
46
|
}
|
|
45
47
|
}
|
|
46
|
-
function
|
|
48
|
+
function executeCreateTests(sourceDir, destinationDir, fileContents, ignoredSourceDir) {
|
|
49
|
+
const entries = fs.readdirSync(sourceDir, { withFileTypes: true });
|
|
50
|
+
if (fs.existsSync(destinationDir) === false) {
|
|
51
|
+
fs.mkdirSync(destinationDir, { recursive: true });
|
|
52
|
+
}
|
|
53
|
+
for (const entry of entries) {
|
|
54
|
+
const sourcePath = path.join(sourceDir, entry.name);
|
|
55
|
+
const destinationPath = path.join(destinationDir, entry.name);
|
|
56
|
+
if (entry.isDirectory()) {
|
|
57
|
+
if (entry.name === 'shared') {
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (ignoredSourceDir !== undefined && sourcePath.startsWith(ignoredSourceDir)) {
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (fs.existsSync(destinationPath) && fs.statSync(destinationPath).isDirectory() === false) {
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
executeCreateTests(sourcePath, destinationPath, fileContents, ignoredSourceDir);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (entry.isFile() && entry.name.endsWith('.ts') && fs.existsSync(destinationPath) === false) {
|
|
70
|
+
fs.writeFileSync(destinationPath, fileContents);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
async function ensureTestsDirectory(testsRootDir) {
|
|
75
|
+
if (fs.existsSync(testsRootDir)) {
|
|
76
|
+
if (fs.statSync(testsRootDir).isDirectory() === false) {
|
|
77
|
+
throw new Error(`Tests path exists but is not a directory: ${testsRootDir}`);
|
|
78
|
+
}
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const rl = readline.createInterface({ input, output });
|
|
82
|
+
try {
|
|
83
|
+
const answer = await rl.question(`Tests directory does not exist at ${testsRootDir}. Create it? (y/N) `);
|
|
84
|
+
if (answer.trim().toLowerCase() !== 'y') {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
finally {
|
|
89
|
+
rl.close();
|
|
90
|
+
}
|
|
91
|
+
fs.mkdirSync(testsRootDir, { recursive: true });
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
async function main(program) {
|
|
47
95
|
const templatesDir = path.join(import.meta.dirname, '..', 'templates');
|
|
48
96
|
const options = program.opts();
|
|
49
97
|
let targetDir = path.resolve(options.dir ?? process.cwd());
|
|
@@ -53,15 +101,15 @@ function main(program) {
|
|
|
53
101
|
if (fs.existsSync(targetDir) === false) {
|
|
54
102
|
fs.mkdirSync(targetDir, { recursive: true });
|
|
55
103
|
}
|
|
56
|
-
if (options.
|
|
57
|
-
targetDir = path.join(targetDir, options.
|
|
58
|
-
|
|
104
|
+
if (options.project !== undefined) {
|
|
105
|
+
targetDir = path.join(targetDir, options.project);
|
|
106
|
+
executeCreateProject(templatesDir, targetDir);
|
|
59
107
|
}
|
|
60
|
-
if (options.react === true && options.
|
|
61
|
-
program.error('Option --react requires --
|
|
108
|
+
if (options.react === true && options.context === undefined) {
|
|
109
|
+
program.error('Option --react requires --context <name>');
|
|
62
110
|
}
|
|
63
|
-
if (options.
|
|
64
|
-
targetDir = path.join(targetDir, options.
|
|
111
|
+
if (options.context !== undefined) {
|
|
112
|
+
targetDir = path.join(targetDir, options.context);
|
|
65
113
|
if (options.react === true) {
|
|
66
114
|
executeCreateReactContext(templatesDir, targetDir);
|
|
67
115
|
}
|
|
@@ -69,14 +117,43 @@ function main(program) {
|
|
|
69
117
|
executeCreateContext(templatesDir, targetDir);
|
|
70
118
|
}
|
|
71
119
|
}
|
|
120
|
+
if (options.tests !== undefined) {
|
|
121
|
+
const sourceDir = path.resolve(options.tests);
|
|
122
|
+
const testsTemplateFile = path.join(templatesDir, 'tests', 'content.ts');
|
|
123
|
+
if (fs.existsSync(sourceDir) === false) {
|
|
124
|
+
program.error(`Tests source directory does not exist: ${sourceDir}`);
|
|
125
|
+
}
|
|
126
|
+
if (fs.statSync(sourceDir).isDirectory() === false) {
|
|
127
|
+
program.error(`Tests source path is not a directory: ${sourceDir}`);
|
|
128
|
+
}
|
|
129
|
+
if (fs.existsSync(testsTemplateFile) === false) {
|
|
130
|
+
program.error(`Tests template file does not exist: ${testsTemplateFile}`);
|
|
131
|
+
}
|
|
132
|
+
const testsTemplateContents = fs.readFileSync(testsTemplateFile, 'utf-8');
|
|
133
|
+
const testsRootDir = path.join(targetDir, 'tests');
|
|
134
|
+
const testsDirectoryCreated = await ensureTestsDirectory(testsRootDir);
|
|
135
|
+
if (testsDirectoryCreated === false) {
|
|
136
|
+
program.error('Tests directory creation cancelled');
|
|
137
|
+
}
|
|
138
|
+
const testsDir = path.join(testsRootDir, path.basename(sourceDir));
|
|
139
|
+
if (testsDir === sourceDir) {
|
|
140
|
+
program.error('Tests destination directory cannot be the same as the source directory');
|
|
141
|
+
}
|
|
142
|
+
executeCreateTests(sourceDir, testsDir, testsTemplateContents, testsRootDir);
|
|
143
|
+
console.log('Tests structure created successfully');
|
|
144
|
+
}
|
|
72
145
|
}
|
|
73
146
|
const packageJson = readPackageJson();
|
|
74
147
|
program
|
|
75
148
|
.name('tshex')
|
|
76
149
|
.version(packageJson.version)
|
|
77
|
-
.option('--
|
|
78
|
-
.option('--
|
|
79
|
-
.option('-R, --react', 'creates a React context with --
|
|
150
|
+
.option('-P, --project <name>', "creates a new project with it's shared directory")
|
|
151
|
+
.option('-C, --context <name>', 'creates a new context')
|
|
152
|
+
.option('-R, --react', 'creates a React context with --context')
|
|
153
|
+
.option('-T, --tests <path>', 'creates a .ts tests structure from an existing directory')
|
|
80
154
|
.option('--dir <path>', 'sets the directory to create the new item')
|
|
81
155
|
.parse(process.argv);
|
|
82
|
-
main(program)
|
|
156
|
+
void main(program).catch((err) => {
|
|
157
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
158
|
+
program.error(message);
|
|
159
|
+
});
|
package/docs/context-ports.md
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
### Context Ports
|
|
2
2
|
|
|
3
3
|
Context ports define the communication available at a context boundary.
|
|
4
|
-
They describe
|
|
5
|
-
|
|
4
|
+
They describe which concrete capability a context exposes, what data enters
|
|
5
|
+
that capability, and what data it returns.
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
In practice a port is usually not just an abstraction or a contract. It is a
|
|
8
|
+
specific boundary element of the system with identity: a command handler, a
|
|
9
|
+
query entry point, an event consumer, a published endpoint, or another concrete
|
|
10
|
+
interaction mechanism that exists because the running system exposes it.
|
|
11
|
+
|
|
12
|
+
Types and interfaces still matter, but they are secondary. Their role is to
|
|
13
|
+
make the port explicit. The main concern is the port as a real executable
|
|
14
|
+
surface that another actor can call or observe.
|
|
10
15
|
|
|
11
16
|
> **Hint**
|
|
12
17
|
> The generated `example-ports.ts` file is only a placeholder. Replace it when
|
|
@@ -22,12 +27,12 @@ export function example(): void {
|
|
|
22
27
|
}
|
|
23
28
|
```
|
|
24
29
|
|
|
25
|
-
This placeholder does not define a real
|
|
26
|
-
|
|
30
|
+
This placeholder does not define a real port yet. Its purpose is to mark the
|
|
31
|
+
context root as the place where boundary-facing capabilities are declared.
|
|
27
32
|
|
|
28
|
-
#### First
|
|
33
|
+
#### First Port
|
|
29
34
|
|
|
30
|
-
In the following example we replace the placeholder with a
|
|
35
|
+
In the following example we replace the placeholder with a concrete port for
|
|
31
36
|
creating a user.
|
|
32
37
|
|
|
33
38
|
```ts title="users/example-ports.ts"
|
|
@@ -53,16 +58,17 @@ export interface CreateUserPort {
|
|
|
53
58
|
```
|
|
54
59
|
|
|
55
60
|
`CreateUserRequest` defines the incoming payload. `CreateUserResponse` defines
|
|
56
|
-
the outgoing payload. `CreateUserPort`
|
|
57
|
-
boundary.
|
|
61
|
+
the outgoing payload. `CreateUserPort` names the concrete capability exposed at
|
|
62
|
+
the boundary: creating a user.
|
|
58
63
|
|
|
59
|
-
This
|
|
60
|
-
the
|
|
64
|
+
This definition does not commit to HTTP, queues, or databases. It focuses on
|
|
65
|
+
the interaction the context makes available. The transport can vary, but the
|
|
66
|
+
port remains the same identifiable boundary capability.
|
|
61
67
|
|
|
62
68
|
#### Adapter Implementation
|
|
63
69
|
|
|
64
|
-
Now that the port exists,
|
|
65
|
-
an application service.
|
|
70
|
+
Now that the port exists, the system can materialize it through an adapter and
|
|
71
|
+
delegate the work to an application service.
|
|
66
72
|
|
|
67
73
|
```ts title="users/adapters/create-user.ts"
|
|
68
74
|
import type {
|
|
@@ -96,22 +102,27 @@ export class CreateUserAdapter implements CreateUserPort {
|
|
|
96
102
|
}
|
|
97
103
|
```
|
|
98
104
|
|
|
99
|
-
The adapter implements `CreateUserPort`, so it
|
|
100
|
-
|
|
101
|
-
application service.
|
|
105
|
+
The adapter implements `CreateUserPort`, so it materializes the boundary
|
|
106
|
+
capability and provides `create()`. Inside that method it translates the
|
|
107
|
+
root-level request into the input expected by the application service.
|
|
102
108
|
|
|
103
109
|
This is the normal flow of the generated structure:
|
|
104
110
|
|
|
105
|
-
```
|
|
106
|
-
|
|
111
|
+
```mermaid
|
|
112
|
+
flowchart LR
|
|
113
|
+
external["External system"] --> adapter[Adapter]
|
|
114
|
+
adapter --> port[Port]
|
|
115
|
+
port --> application[Application]
|
|
116
|
+
application --> domain[Domain]
|
|
107
117
|
```
|
|
108
118
|
|
|
109
|
-
The port belongs to the boundary
|
|
110
|
-
|
|
119
|
+
The port belongs to the boundary because it is part of what the context really
|
|
120
|
+
exposes. The adapter is one implementation path for that port. The application
|
|
121
|
+
process executes the use case and uses domain capabilities.
|
|
111
122
|
|
|
112
123
|
#### Multiple Port Files
|
|
113
124
|
|
|
114
|
-
As the context grows, you can keep several
|
|
125
|
+
As the context grows, you can keep several ports at the context root.
|
|
115
126
|
|
|
116
127
|
```ts title="users/list-users.ts"
|
|
117
128
|
export type ListUsersRequest = {
|
|
@@ -131,35 +142,38 @@ export interface ListUsersPort {
|
|
|
131
142
|
}
|
|
132
143
|
```
|
|
133
144
|
|
|
134
|
-
An adapter can then import the
|
|
145
|
+
An adapter can then import the port definition from the file that owns it.
|
|
135
146
|
|
|
136
|
-
This arrangement is useful when one context exposes several independent
|
|
137
|
-
|
|
138
|
-
become easier to maintain when
|
|
147
|
+
This arrangement is useful when one context exposes several independent
|
|
148
|
+
capabilities. A single file works well for a small context. Separate files
|
|
149
|
+
become easier to maintain when each port has its own identity and
|
|
150
|
+
responsibility.
|
|
139
151
|
|
|
140
152
|
> **Warning**
|
|
141
|
-
> A port should define
|
|
142
|
-
> rules, repository logic, or infrastructure details into
|
|
153
|
+
> A port should define an exposed boundary capability, not domain internals.
|
|
154
|
+
> Avoid moving entity rules, repository logic, or infrastructure details into
|
|
155
|
+
> the port file.
|
|
143
156
|
|
|
144
157
|
#### Example Layout
|
|
145
158
|
|
|
146
159
|
The following structure keeps ports at the root while the implementation lives
|
|
147
160
|
in the generated folders.
|
|
148
161
|
|
|
149
|
-
```
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
162
|
+
```mermaid
|
|
163
|
+
flowchart TD
|
|
164
|
+
users["users/"] --> examplePorts["example-ports.ts"]
|
|
165
|
+
users --> listUsers["list-users.ts"]
|
|
166
|
+
users --> adapters["adapters/"]
|
|
167
|
+
users --> application["application/"]
|
|
168
|
+
users --> domain["domain/"]
|
|
156
169
|
```
|
|
157
170
|
|
|
158
171
|
This layout keeps the context boundary visible from the top level. It also
|
|
159
|
-
reduces coupling between adapters because they all import the same
|
|
172
|
+
reduces coupling between adapters because they all import the same port
|
|
173
|
+
definitions for the capabilities the context exposes.
|
|
160
174
|
|
|
161
175
|
#### Next Step
|
|
162
176
|
|
|
163
|
-
After defining a port, implement the corresponding
|
|
164
|
-
application service. The surrounding structure is described in
|
|
165
|
-
`library-structure.md`.
|
|
177
|
+
After defining a port, implement the corresponding executable path and connect
|
|
178
|
+
it to an application service. The surrounding structure is described in
|
|
179
|
+
`library-structure.md`.
|
|
@@ -13,11 +13,12 @@ language, rules, and operations.
|
|
|
13
13
|
|
|
14
14
|
The root contains the entry points of the generated library.
|
|
15
15
|
|
|
16
|
-
```
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
16
|
+
```mermaid
|
|
17
|
+
flowchart TD
|
|
18
|
+
root["Library root"] --> index["index.d.ts"]
|
|
19
|
+
root --> main["main.ts"]
|
|
20
|
+
root --> shared["shared/"]
|
|
21
|
+
root --> users["users/"]
|
|
21
22
|
```
|
|
22
23
|
|
|
23
24
|
`index.d.ts` defines root-level types. `main.ts` starts as a placeholder for
|
|
@@ -29,10 +30,10 @@ or more context directories.
|
|
|
29
30
|
The `shared` directory contains concepts that can be reused by multiple
|
|
30
31
|
contexts.
|
|
31
32
|
|
|
32
|
-
```
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
33
|
+
```mermaid
|
|
34
|
+
flowchart TD
|
|
35
|
+
shared["shared/"] --> application["application/"]
|
|
36
|
+
shared --> domain["domain/"]
|
|
36
37
|
```
|
|
37
38
|
|
|
38
39
|
`shared/domain` contains modeling foundations such as value objects, entities,
|
|
@@ -47,11 +48,12 @@ Until then, keep it close to the context that owns the rule.
|
|
|
47
48
|
A context groups the vocabulary, rules, and operations of one application
|
|
48
49
|
capability.
|
|
49
50
|
|
|
50
|
-
```
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
51
|
+
```mermaid
|
|
52
|
+
flowchart TD
|
|
53
|
+
contexts["Contexts"] --> users["users/"]
|
|
54
|
+
contexts --> billing["billing/"]
|
|
55
|
+
contexts --> inventory["inventory/"]
|
|
56
|
+
contexts --> sales["sales/"]
|
|
55
57
|
```
|
|
56
58
|
|
|
57
59
|
Each context can evolve independently while still reusing the abstractions from
|
|
@@ -62,12 +64,12 @@ the system.
|
|
|
62
64
|
|
|
63
65
|
Every generated context starts with the same internal structure.
|
|
64
66
|
|
|
65
|
-
```
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
67
|
+
```mermaid
|
|
68
|
+
flowchart TD
|
|
69
|
+
users["users/"] --> ports["example-ports.ts"]
|
|
70
|
+
users --> adapters["adapters/"]
|
|
71
|
+
users --> application["application/"]
|
|
72
|
+
users --> domain["domain/"]
|
|
71
73
|
```
|
|
72
74
|
|
|
73
75
|
`example-ports.ts` is the root communication surface of the context.
|
|
@@ -123,14 +125,20 @@ split ports across several files. The detailed guidance for that layout lives in
|
|
|
123
125
|
|
|
124
126
|
The normal dependency direction is the following:
|
|
125
127
|
|
|
126
|
-
```
|
|
127
|
-
|
|
128
|
-
adapter
|
|
129
|
-
|
|
128
|
+
```mermaid
|
|
129
|
+
flowchart LR
|
|
130
|
+
adapter[Adapter] --> thirdParty["Third-party library"]
|
|
131
|
+
adapter --> port[Port]
|
|
132
|
+
port --> application[Application]
|
|
133
|
+
port --> domain[Domain]
|
|
134
|
+
application --> domain
|
|
130
135
|
```
|
|
131
136
|
|
|
132
137
|
This direction keeps the core model isolated from transport and infrastructure
|
|
133
|
-
details.
|
|
138
|
+
details. Adapters integrate with external libraries and context ports. Ports
|
|
139
|
+
connect the context boundary to application processes or directly to domain
|
|
140
|
+
capabilities when no application orchestration is needed. The deeper a layer
|
|
141
|
+
is, the less it should know about the outside.
|
|
134
142
|
|
|
135
143
|
> **Warning**
|
|
136
144
|
> Avoid importing adapter-specific concerns into the domain layer. Once a domain
|
|
@@ -141,23 +149,23 @@ details. The deeper a layer is, the less it should know about the outside.
|
|
|
141
149
|
|
|
142
150
|
The following diagram shows the runtime flow of a typical operation.
|
|
143
151
|
|
|
144
|
-
```
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
v
|
|
154
|
-
Domain capability
|
|
152
|
+
```mermaid
|
|
153
|
+
flowchart TD
|
|
154
|
+
main["main.ts"] --> system["Own system"]
|
|
155
|
+
system --> application["Application services"]
|
|
156
|
+
application --> domain["Domain capabilities"]
|
|
157
|
+
system --> adapter[Adapters]
|
|
158
|
+
adapter --> port[Port]
|
|
159
|
+
adapter --> thirdParty["Third-party libraries"]
|
|
160
|
+
thirdParty --> external["External systems"]
|
|
155
161
|
```
|
|
156
162
|
|
|
157
|
-
|
|
158
|
-
|
|
163
|
+
`main.ts` is the runtime entry point into the own system. Inside that system,
|
|
164
|
+
application services use domain capabilities, while adapters can depend on
|
|
165
|
+
ports and third-party libraries. The port branch stops at the boundary because
|
|
166
|
+
what exists beyond that port depends on the system that implements it.
|
|
159
167
|
|
|
160
168
|
#### Next Step
|
|
161
169
|
|
|
162
170
|
Use this structure as the default layout for new code. When you need to inspect
|
|
163
|
-
the purpose of a generated file, consult `generated-file-reference.md`.
|
|
171
|
+
the purpose of a generated file, consult `generated-file-reference.md`.
|
|
@@ -176,10 +176,15 @@ and the representation used by the context.
|
|
|
176
176
|
|
|
177
177
|
The normal flow of the data abstractions is the following:
|
|
178
178
|
|
|
179
|
-
```
|
|
180
|
-
|
|
181
|
-
service
|
|
179
|
+
```mermaid
|
|
180
|
+
flowchart LR
|
|
181
|
+
service[Service] --> repository[Repository]
|
|
182
|
+
repository --> driver[Driver]
|
|
183
|
+
driver --> manager["Data manager"]
|
|
184
|
+
manager --> raw["Raw records"]
|
|
185
|
+
repository --> transformed["Transformed records"]
|
|
186
|
+
transformed --> service
|
|
182
187
|
```
|
|
183
188
|
|
|
184
189
|
This separation keeps the application service focused on orchestration while
|
|
185
|
-
the repository focuses on transformation.
|
|
190
|
+
the repository focuses on transformation.
|
|
@@ -135,10 +135,12 @@ registered or executed.
|
|
|
135
135
|
|
|
136
136
|
The normal flow is the following:
|
|
137
137
|
|
|
138
|
-
```
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
138
|
+
```mermaid
|
|
139
|
+
flowchart LR
|
|
140
|
+
service[Service] --> dispatch["dispatch(event)"]
|
|
141
|
+
dispatch --> dispatcher[Dispatcher]
|
|
142
|
+
dispatcher --> handlers["Matching handlers"]
|
|
143
|
+
handlers --> effects["Side effects"]
|
|
142
144
|
```
|
|
143
145
|
|
|
144
|
-
This keeps the main process separate from secondary reactions.
|
|
146
|
+
This keeps the main process separate from secondary reactions.
|
|
@@ -123,9 +123,12 @@ cross-cutting behavior belongs in the generated HTTP abstraction.
|
|
|
123
123
|
|
|
124
124
|
#### Example Flow
|
|
125
125
|
|
|
126
|
-
```
|
|
127
|
-
|
|
126
|
+
```mermaid
|
|
127
|
+
flowchart LR
|
|
128
|
+
request[Request] --> middleware[Middleware]
|
|
129
|
+
middleware --> handler[Handler]
|
|
130
|
+
handler --> response["Response body"]
|
|
128
131
|
```
|
|
129
132
|
|
|
130
133
|
This flow keeps the transport boundary explicit while leaving framework choices
|
|
131
|
-
to the adapter layer.
|
|
134
|
+
to the adapter layer.
|
|
@@ -120,8 +120,11 @@ an external platform. It only depends on the application-level contract.
|
|
|
120
120
|
|
|
121
121
|
#### Example Flow
|
|
122
122
|
|
|
123
|
-
```
|
|
124
|
-
|
|
123
|
+
```mermaid
|
|
124
|
+
flowchart LR
|
|
125
|
+
service[Service] --> contract["Logger contract"]
|
|
126
|
+
contract --> adapter[Adapter]
|
|
127
|
+
adapter --> backend["Logging backend"]
|
|
125
128
|
```
|
|
126
129
|
|
|
127
|
-
This flow keeps observability concerns outside the core process.
|
|
130
|
+
This flow keeps observability concerns outside the core process.
|
|
@@ -112,9 +112,13 @@ result, depending on the requirements of the use case.
|
|
|
112
112
|
|
|
113
113
|
#### Example Flow
|
|
114
114
|
|
|
115
|
-
```
|
|
116
|
-
|
|
115
|
+
```mermaid
|
|
116
|
+
flowchart LR
|
|
117
|
+
input[Input] --> service[Service]
|
|
118
|
+
service --> domain["Domain capabilities"]
|
|
119
|
+
domain --> collaborators[Collaborators]
|
|
120
|
+
collaborators --> result[Result]
|
|
117
121
|
```
|
|
118
122
|
|
|
119
123
|
This flow keeps orchestration in the application layer and domain meaning in
|
|
120
|
-
the domain layer.
|
|
124
|
+
the domain layer.
|
package/package.json
CHANGED
package/{README.md → readme.md}
RENAMED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# Hexagonal Architecture CLI `tshex-cli`
|
|
2
2
|
|
|
3
|
-
`tshex-cli` creates the base structure of a
|
|
3
|
+
`tshex-cli` creates the base structure of a project organized by contexts. The structure groups shared contracts, domain concepts, use cases, and adapters into directories with defined responsibilities.
|
|
4
4
|
|
|
5
|
-
In this guide, we will build a
|
|
5
|
+
In this guide, we will build a project named `core` with a context named `users`. The walkthrough starts with the CLI and then explains the purpose of the generated components.
|
|
6
6
|
|
|
7
7
|
The examples in this guide are intentionally simple. They are designed to show the responsibility of each component, not to cover real infrastructure or production scenarios.
|
|
8
8
|
|
|
@@ -38,32 +38,44 @@ View the available commands and options with:
|
|
|
38
38
|
npx tshex --help
|
|
39
39
|
```
|
|
40
40
|
|
|
41
|
-
### Create a
|
|
41
|
+
### Create a project
|
|
42
42
|
|
|
43
|
-
The `--
|
|
43
|
+
The `--project` option receives the name of the project's root directory:
|
|
44
44
|
|
|
45
45
|
```bash
|
|
46
|
-
npx tshex --
|
|
46
|
+
npx tshex --project core
|
|
47
47
|
```
|
|
48
48
|
|
|
49
|
-
|
|
49
|
+
You can also use the short form:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
npx tshex -P core
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
In this example, `core` will contain the shared code, the contexts, and the project's main implementation.
|
|
50
56
|
|
|
51
57
|
### Create a context
|
|
52
58
|
|
|
53
|
-
The `--
|
|
59
|
+
The `--context` option receives the context name:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
npx tshex --context users
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
You can also use the short form:
|
|
54
66
|
|
|
55
67
|
```bash
|
|
56
|
-
npx tshex
|
|
68
|
+
npx tshex -C users
|
|
57
69
|
```
|
|
58
70
|
|
|
59
71
|
A context groups the rules and operations of an application capability. `users`, `sales`, `billing`, and `inventory` are examples of contexts.
|
|
60
72
|
|
|
61
|
-
### Create the
|
|
73
|
+
### Create the project and the first context
|
|
62
74
|
|
|
63
75
|
You can generate both components in a single execution:
|
|
64
76
|
|
|
65
77
|
```bash
|
|
66
|
-
npx tshex --
|
|
78
|
+
npx tshex --project core --context users
|
|
67
79
|
```
|
|
68
80
|
|
|
69
81
|
The command creates this structure:
|
|
@@ -100,15 +112,15 @@ core/
|
|
|
100
112
|
The `--dir` option specifies the directory from which the structure is created:
|
|
101
113
|
|
|
102
114
|
```bash
|
|
103
|
-
npx tshex --dir ./src --
|
|
115
|
+
npx tshex --dir ./src --project core --context users
|
|
104
116
|
```
|
|
105
117
|
|
|
106
|
-
The example
|
|
118
|
+
The example project is created at `src/core`.
|
|
107
119
|
|
|
108
|
-
To add a context to an existing
|
|
120
|
+
To add a context to an existing project, use the project as the destination directory:
|
|
109
121
|
|
|
110
122
|
```bash
|
|
111
|
-
npx tshex --dir ./core --
|
|
123
|
+
npx tshex --dir ./core --context billing
|
|
112
124
|
```
|
|
113
125
|
|
|
114
126
|
The context is created at `core/billing`.
|
|
@@ -122,13 +134,13 @@ A React context is a consumer by nature. It does not provide a hexagonal capabil
|
|
|
122
134
|
Use this mode when the generated context will call APIs, validate interface data, expose hooks, compose components, and localize messages.
|
|
123
135
|
|
|
124
136
|
```bash
|
|
125
|
-
npx tshex --
|
|
137
|
+
npx tshex --context users --react
|
|
126
138
|
```
|
|
127
139
|
|
|
128
140
|
You can also use its short form:
|
|
129
141
|
|
|
130
142
|
```bash
|
|
131
|
-
npx tshex
|
|
143
|
+
npx tshex -C users -R
|
|
132
144
|
```
|
|
133
145
|
|
|
134
146
|
The command creates this structure:
|
|
@@ -158,14 +170,61 @@ Each directory represents a consumption adapter or a resource used by those adap
|
|
|
158
170
|
|
|
159
171
|
This layout is intentionally different from the default context template. The standard context separates `domain`, `application`, and `adapters` because it models and provides a capability. The React context generated with `--react` assumes the opposite role: it always consumes capabilities and groups the code around the adapters required by that consumption.
|
|
160
172
|
|
|
173
|
+
### Create a tests structure
|
|
174
|
+
|
|
175
|
+
The `--tests` option receives a source directory and creates a matching tests structure.
|
|
176
|
+
|
|
177
|
+
```bash
|
|
178
|
+
npx tshex --tests ./core
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
You can also use the short form:
|
|
182
|
+
|
|
183
|
+
```bash
|
|
184
|
+
npx tshex -T ./core
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
The command creates the output inside a `tests/` directory. If `tests/` does not exist, the CLI asks whether it should be created.
|
|
188
|
+
|
|
189
|
+
By default, the `tests/` directory is resolved from the current execution directory:
|
|
190
|
+
|
|
191
|
+
```bash
|
|
192
|
+
npx tshex -T ./core
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
This generates a structure like this:
|
|
196
|
+
|
|
197
|
+
```text
|
|
198
|
+
tests/
|
|
199
|
+
`-- core/
|
|
200
|
+
|-- users/
|
|
201
|
+
| |-- adapters/
|
|
202
|
+
| |-- application/
|
|
203
|
+
| | `-- create-user.ts
|
|
204
|
+
| `-- domain/
|
|
205
|
+
`-- billing/
|
|
206
|
+
|-- application/
|
|
207
|
+
`-- domain/
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
To choose another base directory for `tests/`, combine `--tests` with `--dir`:
|
|
211
|
+
|
|
212
|
+
```bash
|
|
213
|
+
npx tshex -T ./core --dir ./output
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
That command creates or reuses `./output/tests/`.
|
|
217
|
+
|
|
218
|
+
This command helps you prepare a tests workspace that follows the shape of your source directory while fitting naturally into the place where you are working. You can use it in the current directory for a quick setup, or combine it with `--dir` when you want the tests structure to be created somewhere else. If `tests/` already contains content, the command continues working with what is already there instead of interrupting your flow.
|
|
219
|
+
|
|
161
220
|
## Documentation index
|
|
162
221
|
|
|
163
222
|
From this point on, the guide is split into dedicated documents under `docs/`.
|
|
164
223
|
|
|
165
224
|
### General
|
|
166
225
|
|
|
167
|
-
- [
|
|
168
|
-
- [
|
|
226
|
+
- [Project structure](https://github.com/virtualitems/tshex-cli/blob/main/docs/library-structure.md)
|
|
227
|
+
- [Project types](https://github.com/virtualitems/tshex-cli/blob/main/docs/library-types.md)
|
|
169
228
|
- [Context ports](https://github.com/virtualitems/tshex-cli/blob/main/docs/context-ports.md)
|
|
170
229
|
- [Generated file reference](https://github.com/virtualitems/tshex-cli/blob/main/docs/generated-file-reference.md)
|
|
171
230
|
|
package/source/main.ts
CHANGED
|
@@ -8,6 +8,8 @@ import { program } from 'commander'
|
|
|
8
8
|
|
|
9
9
|
import fs from 'node:fs'
|
|
10
10
|
import path from 'node:path'
|
|
11
|
+
import readline from 'node:readline/promises'
|
|
12
|
+
import { stdin as input, stdout as output } from 'node:process'
|
|
11
13
|
|
|
12
14
|
// FUNCTIONS
|
|
13
15
|
|
|
@@ -17,13 +19,13 @@ function readPackageJson() {
|
|
|
17
19
|
return JSON.parse(fileContents)
|
|
18
20
|
}
|
|
19
21
|
|
|
20
|
-
function
|
|
22
|
+
function executeCreateProject(templatesDir: string, projectDir: string) {
|
|
21
23
|
try {
|
|
22
|
-
fs.cpSync(path.join(templatesDir, 'lib'),
|
|
24
|
+
fs.cpSync(path.join(templatesDir, 'lib'), projectDir, {
|
|
23
25
|
recursive: true,
|
|
24
26
|
filter: (src) => src.endsWith('.gitkeep') === false
|
|
25
27
|
})
|
|
26
|
-
console.log('
|
|
28
|
+
console.log('Project created successfully')
|
|
27
29
|
} catch (err) {
|
|
28
30
|
console.error(err)
|
|
29
31
|
}
|
|
@@ -53,7 +55,66 @@ function executeCreateReactContext(templatesDir: string, contextDir: string) {
|
|
|
53
55
|
}
|
|
54
56
|
}
|
|
55
57
|
|
|
56
|
-
function
|
|
58
|
+
function executeCreateTests(sourceDir: string, destinationDir: string, fileContents: string, ignoredSourceDir?: string) {
|
|
59
|
+
const entries = fs.readdirSync(sourceDir, { withFileTypes: true })
|
|
60
|
+
|
|
61
|
+
if (fs.existsSync(destinationDir) === false) {
|
|
62
|
+
fs.mkdirSync(destinationDir, { recursive: true })
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
for (const entry of entries) {
|
|
66
|
+
const sourcePath = path.join(sourceDir, entry.name)
|
|
67
|
+
const destinationPath = path.join(destinationDir, entry.name)
|
|
68
|
+
|
|
69
|
+
if (entry.isDirectory()) {
|
|
70
|
+
if (entry.name === 'shared') {
|
|
71
|
+
continue
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (ignoredSourceDir !== undefined && sourcePath.startsWith(ignoredSourceDir)) {
|
|
75
|
+
continue
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (fs.existsSync(destinationPath) && fs.statSync(destinationPath).isDirectory() === false) {
|
|
79
|
+
continue
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
executeCreateTests(sourcePath, destinationPath, fileContents, ignoredSourceDir)
|
|
83
|
+
continue
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (entry.isFile() && entry.name.endsWith('.ts') && fs.existsSync(destinationPath) === false) {
|
|
87
|
+
fs.writeFileSync(destinationPath, fileContents)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function ensureTestsDirectory(testsRootDir: string) {
|
|
93
|
+
if (fs.existsSync(testsRootDir)) {
|
|
94
|
+
if (fs.statSync(testsRootDir).isDirectory() === false) {
|
|
95
|
+
throw new Error(`Tests path exists but is not a directory: ${testsRootDir}`)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const rl = readline.createInterface({ input, output })
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
const answer = await rl.question(`Tests directory does not exist at ${testsRootDir}. Create it? (y/N) `)
|
|
105
|
+
|
|
106
|
+
if (answer.trim().toLowerCase() !== 'y') {
|
|
107
|
+
return false
|
|
108
|
+
}
|
|
109
|
+
} finally {
|
|
110
|
+
rl.close()
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
fs.mkdirSync(testsRootDir, { recursive: true })
|
|
114
|
+
return true
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function main(program: typeof import('commander').program) {
|
|
57
118
|
const templatesDir = path.join(import.meta.dirname, '..', 'templates')
|
|
58
119
|
|
|
59
120
|
const options = program.opts()
|
|
@@ -68,17 +129,17 @@ function main(program: typeof import('commander').program) {
|
|
|
68
129
|
fs.mkdirSync(targetDir, { recursive: true })
|
|
69
130
|
}
|
|
70
131
|
|
|
71
|
-
if (options.
|
|
72
|
-
targetDir = path.join(targetDir, options.
|
|
73
|
-
|
|
132
|
+
if (options.project !== undefined) {
|
|
133
|
+
targetDir = path.join(targetDir, options.project)
|
|
134
|
+
executeCreateProject(templatesDir, targetDir)
|
|
74
135
|
}
|
|
75
136
|
|
|
76
|
-
if (options.react === true && options.
|
|
77
|
-
program.error('Option --react requires --
|
|
137
|
+
if (options.react === true && options.context === undefined) {
|
|
138
|
+
program.error('Option --react requires --context <name>')
|
|
78
139
|
}
|
|
79
140
|
|
|
80
|
-
if (options.
|
|
81
|
-
targetDir = path.join(targetDir, options.
|
|
141
|
+
if (options.context !== undefined) {
|
|
142
|
+
targetDir = path.join(targetDir, options.context)
|
|
82
143
|
|
|
83
144
|
if (options.react === true) {
|
|
84
145
|
executeCreateReactContext(templatesDir, targetDir)
|
|
@@ -86,6 +147,41 @@ function main(program: typeof import('commander').program) {
|
|
|
86
147
|
executeCreateContext(templatesDir, targetDir)
|
|
87
148
|
}
|
|
88
149
|
}
|
|
150
|
+
|
|
151
|
+
if (options.tests !== undefined) {
|
|
152
|
+
const sourceDir = path.resolve(options.tests)
|
|
153
|
+
const testsTemplateFile = path.join(templatesDir, 'tests', 'content.ts')
|
|
154
|
+
|
|
155
|
+
if (fs.existsSync(sourceDir) === false) {
|
|
156
|
+
program.error(`Tests source directory does not exist: ${sourceDir}`)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (fs.statSync(sourceDir).isDirectory() === false) {
|
|
160
|
+
program.error(`Tests source path is not a directory: ${sourceDir}`)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (fs.existsSync(testsTemplateFile) === false) {
|
|
164
|
+
program.error(`Tests template file does not exist: ${testsTemplateFile}`)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const testsTemplateContents = fs.readFileSync(testsTemplateFile, 'utf-8')
|
|
168
|
+
|
|
169
|
+
const testsRootDir = path.join(targetDir, 'tests')
|
|
170
|
+
const testsDirectoryCreated = await ensureTestsDirectory(testsRootDir)
|
|
171
|
+
|
|
172
|
+
if (testsDirectoryCreated === false) {
|
|
173
|
+
program.error('Tests directory creation cancelled')
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const testsDir = path.join(testsRootDir, path.basename(sourceDir))
|
|
177
|
+
|
|
178
|
+
if (testsDir === sourceDir) {
|
|
179
|
+
program.error('Tests destination directory cannot be the same as the source directory')
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
executeCreateTests(sourceDir, testsDir, testsTemplateContents, testsRootDir)
|
|
183
|
+
console.log('Tests structure created successfully')
|
|
184
|
+
}
|
|
89
185
|
}
|
|
90
186
|
|
|
91
187
|
const packageJson = readPackageJson()
|
|
@@ -93,10 +189,14 @@ const packageJson = readPackageJson()
|
|
|
93
189
|
program
|
|
94
190
|
.name('tshex')
|
|
95
191
|
.version(packageJson.version)
|
|
96
|
-
.option('--
|
|
97
|
-
.option('--
|
|
98
|
-
.option('-R, --react', 'creates a React context with --
|
|
192
|
+
.option('-P, --project <name>', "creates a new project with it's shared directory")
|
|
193
|
+
.option('-C, --context <name>', 'creates a new context')
|
|
194
|
+
.option('-R, --react', 'creates a React context with --context')
|
|
195
|
+
.option('-T, --tests <path>', 'creates a .ts tests structure from an existing directory')
|
|
99
196
|
.option('--dir <path>', 'sets the directory to create the new item')
|
|
100
197
|
.parse(process.argv)
|
|
101
198
|
|
|
102
|
-
main(program)
|
|
199
|
+
void main(program).catch((err: unknown) => {
|
|
200
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
201
|
+
program.error(message)
|
|
202
|
+
})
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { test, describe } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
|
|
4
|
+
describe('math', () => {
|
|
5
|
+
test('sum', () => {
|
|
6
|
+
// Arrange
|
|
7
|
+
const a = 2
|
|
8
|
+
const b = 3
|
|
9
|
+
|
|
10
|
+
// Act
|
|
11
|
+
const result = a * b
|
|
12
|
+
|
|
13
|
+
// Assert
|
|
14
|
+
assert.strictEqual(result, 6)
|
|
15
|
+
})
|
|
16
|
+
})
|