@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.
Files changed (4) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +13 -0
  3. package/index.js +277 -0
  4. 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
+ }