@lazyapps/command-replay 0.1.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/LICENSE +15 -0
- package/README.md +13 -0
- package/index.js +277 -0
- package/package.json +45 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
ISC License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026, Oliver Sturm
|
|
4
|
+
|
|
5
|
+
Permission to use, copy, modify, and/or distribute this software for any
|
|
6
|
+
purpose with or without fee is hereby granted, provided that the above
|
|
7
|
+
copyright notice and this permission notice appear in all copies.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
|
10
|
+
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
|
11
|
+
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
|
12
|
+
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
|
13
|
+
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
|
14
|
+
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
|
15
|
+
PERFORMANCE OF THIS SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# @lazyapps/command-replay
|
|
2
|
+
|
|
3
|
+
CLI tool to replay recorded commands for testing and recovery. Reads command recordings and replays them against a LazyApps command processor.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pnpm add @lazyapps/command-replay
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Part of LazyApps
|
|
12
|
+
|
|
13
|
+
This package is part of the [LazyApps](https://github.com/oliversturm/lazyapps-libs) event-sourcing and CQRS framework.
|
package/index.js
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import readline from 'readline';
|
|
3
|
+
import fetch from 'node-fetch';
|
|
4
|
+
import enquirer from 'enquirer';
|
|
5
|
+
import jwt from 'jsonwebtoken';
|
|
6
|
+
import { getLogger } from '@lazyapps/logger';
|
|
7
|
+
import { Command } from 'commander';
|
|
8
|
+
|
|
9
|
+
const { prompt } = enquirer;
|
|
10
|
+
const log = getLogger('CommandReplay');
|
|
11
|
+
|
|
12
|
+
let baseUrl;
|
|
13
|
+
let commandApiUrl;
|
|
14
|
+
let tokenApiUrl;
|
|
15
|
+
let adminApiUrl;
|
|
16
|
+
|
|
17
|
+
const setBaseUrl = (url) => {
|
|
18
|
+
baseUrl = url;
|
|
19
|
+
commandApiUrl = `${baseUrl}/api/command`;
|
|
20
|
+
tokenApiUrl = `${baseUrl}/api/tokens`;
|
|
21
|
+
adminApiUrl = `${baseUrl}/api/admin`;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const delay = (ms) => () => new Promise((resolve) => setTimeout(resolve, ms));
|
|
25
|
+
|
|
26
|
+
const readCommandFile = (filePath) =>
|
|
27
|
+
fs.open(filePath).then((fileHandle) => {
|
|
28
|
+
const rl = readline.createInterface({
|
|
29
|
+
input: fileHandle.createReadStream(),
|
|
30
|
+
crlfDelay: Infinity,
|
|
31
|
+
});
|
|
32
|
+
return { rl, fileHandle };
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const replayCommand = async (command, options) => {
|
|
36
|
+
let token = options.token;
|
|
37
|
+
|
|
38
|
+
if (!options.noAuth && command.auth) {
|
|
39
|
+
if (command.auth.user === options.username) {
|
|
40
|
+
// Case 1: Auth matches current user - use the token we got from the token service
|
|
41
|
+
token = options.token;
|
|
42
|
+
} else {
|
|
43
|
+
// Case 3: Auth exists but different user - create self-signed token
|
|
44
|
+
token = await createSelfSignedToken(
|
|
45
|
+
command.auth,
|
|
46
|
+
options.jwtsecretPath,
|
|
47
|
+
options.issuer,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
} else {
|
|
51
|
+
// Case 2: No auth or noAuth option - don't send a token
|
|
52
|
+
token = undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const headers = {
|
|
56
|
+
'Content-Type': 'application/json',
|
|
57
|
+
...(token && { Authorization: `Bearer ${token}` }),
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
return fetch(commandApiUrl, {
|
|
61
|
+
method: 'POST',
|
|
62
|
+
headers,
|
|
63
|
+
body: JSON.stringify({
|
|
64
|
+
command: command.command,
|
|
65
|
+
aggregateName: command.aggregateName,
|
|
66
|
+
aggregateId: command.aggregateId,
|
|
67
|
+
payload: command.payload,
|
|
68
|
+
}),
|
|
69
|
+
}).then((response) => {
|
|
70
|
+
if (!response.ok) {
|
|
71
|
+
const error = new Error(
|
|
72
|
+
`Failed to replay command: ${response.statusText}`,
|
|
73
|
+
);
|
|
74
|
+
if (!options.continueOnError) throw error;
|
|
75
|
+
log.error(error.message);
|
|
76
|
+
}
|
|
77
|
+
return options.delayBetweenCommands > 0
|
|
78
|
+
? delay(options.delayBetweenCommands)()
|
|
79
|
+
: Promise.resolve();
|
|
80
|
+
});
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const createSelfSignedToken = async (auth, jwtsecretPath, issuer) => {
|
|
84
|
+
if (!jwtsecretPath) {
|
|
85
|
+
throw new Error('JWT secret file is required for self-signing tokens');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const cachedToken = tokenCache.get(auth.user);
|
|
89
|
+
if (cachedToken) return cachedToken;
|
|
90
|
+
|
|
91
|
+
const jwtsecret = (await fs.readFile(jwtsecretPath, 'utf8')).trim();
|
|
92
|
+
const tokenPayload = { ...auth };
|
|
93
|
+
delete tokenPayload.iat;
|
|
94
|
+
delete tokenPayload.exp;
|
|
95
|
+
delete tokenPayload.iss;
|
|
96
|
+
|
|
97
|
+
const token = jwt.sign(tokenPayload, jwtsecret, {
|
|
98
|
+
expiresIn: '6h',
|
|
99
|
+
...(issuer && { issuer }),
|
|
100
|
+
});
|
|
101
|
+
tokenCache.set(auth.user, token);
|
|
102
|
+
return token;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
let tokenCache = new Map();
|
|
106
|
+
|
|
107
|
+
const processLine = (line, options) =>
|
|
108
|
+
Promise.resolve()
|
|
109
|
+
.then(() => {
|
|
110
|
+
try {
|
|
111
|
+
return JSON.parse(line);
|
|
112
|
+
} catch (error) {
|
|
113
|
+
log.error(`Failed to parse command: ${error.message}`);
|
|
114
|
+
if (!options.continueOnError) throw error;
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
})
|
|
118
|
+
.then((command) => {
|
|
119
|
+
if (!command) return Promise.resolve();
|
|
120
|
+
log.info(`Replaying command: ${command.command}`);
|
|
121
|
+
return replayCommand(command, options);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const fetchToken = ({ username, password }) =>
|
|
125
|
+
fetch(tokenApiUrl + '/getJwt', {
|
|
126
|
+
method: 'POST',
|
|
127
|
+
headers: {
|
|
128
|
+
'Content-Type': 'application/json',
|
|
129
|
+
Accept: 'text/plain',
|
|
130
|
+
},
|
|
131
|
+
body: JSON.stringify({
|
|
132
|
+
username,
|
|
133
|
+
password,
|
|
134
|
+
}),
|
|
135
|
+
}).then((res) => res.text());
|
|
136
|
+
|
|
137
|
+
const readUserNameAndPassword = () =>
|
|
138
|
+
prompt([
|
|
139
|
+
{ type: 'input', name: 'username', message: 'Username:' },
|
|
140
|
+
{
|
|
141
|
+
type: 'password',
|
|
142
|
+
name: 'password',
|
|
143
|
+
message: 'Password:',
|
|
144
|
+
},
|
|
145
|
+
]);
|
|
146
|
+
|
|
147
|
+
const setReplayState = async (state, token) => {
|
|
148
|
+
log.info(`Setting replay state to: ${state}`);
|
|
149
|
+
|
|
150
|
+
return fetch(`${adminApiUrl}/setReplayState`, {
|
|
151
|
+
method: 'POST',
|
|
152
|
+
headers: {
|
|
153
|
+
'Content-Type': 'application/json',
|
|
154
|
+
Authorization: `Bearer ${token}`,
|
|
155
|
+
},
|
|
156
|
+
body: JSON.stringify({
|
|
157
|
+
params: { state },
|
|
158
|
+
}),
|
|
159
|
+
}).then((response) => {
|
|
160
|
+
if (!response.ok) {
|
|
161
|
+
throw new Error(`Failed to set replay state: ${response.statusText}`);
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const program = new Command();
|
|
167
|
+
program
|
|
168
|
+
.requiredOption('-f, --file <path>', 'Command file to replay')
|
|
169
|
+
.option('-u, --url <url>', 'Base URL for the API', 'http://localhost')
|
|
170
|
+
.option('-d, --delay <ms>', 'Delay between commands in milliseconds', '200')
|
|
171
|
+
.option(
|
|
172
|
+
'-c, --continue-on-error',
|
|
173
|
+
'Continue processing if a command fails',
|
|
174
|
+
false,
|
|
175
|
+
)
|
|
176
|
+
.option(
|
|
177
|
+
'-n, --noAuth',
|
|
178
|
+
'Skip authentication (no username/password query, no token handling)',
|
|
179
|
+
false,
|
|
180
|
+
)
|
|
181
|
+
.option(
|
|
182
|
+
'-j, --jwtsecret <path>',
|
|
183
|
+
'Path to JWT secret file for self-signing tokens',
|
|
184
|
+
)
|
|
185
|
+
.option('-i, --issuer <name>', 'Issuer name for self-signed tokens')
|
|
186
|
+
.parse();
|
|
187
|
+
|
|
188
|
+
const options = program.opts();
|
|
189
|
+
|
|
190
|
+
// Set base URL immediately
|
|
191
|
+
setBaseUrl(options.url);
|
|
192
|
+
|
|
193
|
+
// Convert delay to number
|
|
194
|
+
options.delayBetweenCommands = parseInt(options.delay, 10);
|
|
195
|
+
|
|
196
|
+
// Show summary and ask for confirmation
|
|
197
|
+
const showSummaryAndConfirm = async () => {
|
|
198
|
+
console.log('\nCommand Replay Configuration:');
|
|
199
|
+
console.log('----------------------------');
|
|
200
|
+
console.log(`Command File: ${options.file}`);
|
|
201
|
+
console.log(`Base URL: ${options.url}`);
|
|
202
|
+
console.log(`Delay between commands: ${options.delayBetweenCommands}ms`);
|
|
203
|
+
console.log(`Continue on error: ${options.continueOnError}`);
|
|
204
|
+
console.log(`Skip authentication: ${options.noAuth}`);
|
|
205
|
+
if (!options.noAuth && options.jwtsecret) {
|
|
206
|
+
console.log(`JWT Secret File: ${options.jwtsecret}`);
|
|
207
|
+
if (options.issuer) {
|
|
208
|
+
console.log(`Token Issuer: ${options.issuer}`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
console.log('----------------------------\n');
|
|
212
|
+
|
|
213
|
+
let token, username;
|
|
214
|
+
if (!options.noAuth) {
|
|
215
|
+
const credentials = await readUserNameAndPassword();
|
|
216
|
+
token = await fetchToken(credentials);
|
|
217
|
+
username = credentials.username;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const { confirmed } = await prompt({
|
|
221
|
+
type: 'confirm',
|
|
222
|
+
name: 'confirmed',
|
|
223
|
+
message: 'Do you want to proceed with these settings?',
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
if (!confirmed) {
|
|
227
|
+
console.log('Operation cancelled by user');
|
|
228
|
+
process.exit(0);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return { token, username };
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
// Main execution
|
|
235
|
+
showSummaryAndConfirm()
|
|
236
|
+
.then(({ token, username }) => {
|
|
237
|
+
const options = {
|
|
238
|
+
...program.opts(),
|
|
239
|
+
token,
|
|
240
|
+
username,
|
|
241
|
+
jwtsecretPath: program.opts().jwtsecret,
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
return setReplayState(true, token)
|
|
245
|
+
.then(() =>
|
|
246
|
+
readCommandFile(options.file).then(
|
|
247
|
+
({ rl, fileHandle }) =>
|
|
248
|
+
new Promise((resolve, reject) => {
|
|
249
|
+
let processPromise = Promise.resolve();
|
|
250
|
+
|
|
251
|
+
rl.on('line', (line) => {
|
|
252
|
+
processPromise = processPromise
|
|
253
|
+
.then(() => processLine(line, options))
|
|
254
|
+
.catch(reject);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
rl.on('close', () => {
|
|
258
|
+
processPromise
|
|
259
|
+
.then(() => fileHandle.close())
|
|
260
|
+
.then(resolve)
|
|
261
|
+
.catch(reject);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
rl.on('error', reject);
|
|
265
|
+
}),
|
|
266
|
+
),
|
|
267
|
+
)
|
|
268
|
+
.finally(() =>
|
|
269
|
+
setReplayState(false, token).catch((stateError) => {
|
|
270
|
+
log.error(`Failed to disable replay state: ${stateError}`);
|
|
271
|
+
}),
|
|
272
|
+
);
|
|
273
|
+
})
|
|
274
|
+
.catch((err) => {
|
|
275
|
+
log.error(`Error during command replay: ${err}`);
|
|
276
|
+
process.exit(1);
|
|
277
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@lazyapps/command-replay",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "CLI tool to replay recorded commands for testing and recovery in LazyApps",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"index.js"
|
|
11
|
+
],
|
|
12
|
+
"bin": {
|
|
13
|
+
"command-replay": "./index.js"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"event-sourcing",
|
|
17
|
+
"cqrs",
|
|
18
|
+
"lazyapps",
|
|
19
|
+
"command-replay",
|
|
20
|
+
"cli"
|
|
21
|
+
],
|
|
22
|
+
"author": "Oliver Sturm",
|
|
23
|
+
"license": "ISC",
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "git+https://github.com/oliversturm/lazyapps-libs.git",
|
|
27
|
+
"directory": "packages/command-replay"
|
|
28
|
+
},
|
|
29
|
+
"bugs": {
|
|
30
|
+
"url": "https://github.com/oliversturm/lazyapps-libs/issues"
|
|
31
|
+
},
|
|
32
|
+
"homepage": "https://github.com/oliversturm/lazyapps-libs/tree/main/packages/command-replay#readme",
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=18.20.3 || >=20.18.0"
|
|
35
|
+
},
|
|
36
|
+
"type": "module",
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"commander": "^13.1.0",
|
|
39
|
+
"enquirer": "^2.3.6",
|
|
40
|
+
"jsonwebtoken": "^9.0.2",
|
|
41
|
+
"node-fetch": "^3.3.0",
|
|
42
|
+
"@lazyapps/logger": "^0.1.0"
|
|
43
|
+
},
|
|
44
|
+
"scripts": {}
|
|
45
|
+
}
|