@laphilosophia/api-tape 1.1.0 → 1.2.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 (3) hide show
  1. package/README.md +23 -7
  2. package/dist/index.js +261 -182
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -9,6 +9,8 @@ API Tape is a zero-config CLI tool that acts as a transparent HTTP proxy. It rec
9
9
  - 🎬 **Record Mode** — Proxies requests to your target API and saves responses
10
10
  - 🔄 **Replay Mode** — Serves cached responses instantly from disk
11
11
  - 🔀 **Hybrid Mode** — Replays cached tapes, falls back to upstream on cache miss
12
+ - 🧰 **Tape Management Commands** — List, inspect, clear, and prune tapes from CLI
13
+ - 🔐 **Header Redaction** — Mask sensitive response headers before writing tapes
12
14
  - 📦 **Zero Config** — Works out of the box with sensible defaults
13
15
  - 🔒 **Binary Safe** — Handles images, compressed responses, and any content type
14
16
  - 🏷️ **Replay Header** — Responses include `X-Api-Tape: Replayed` for easy debugging
@@ -70,13 +72,27 @@ tape --target "https://jsonplaceholder.typicode.com" --mode hybrid --record-on-m
70
72
 
71
73
  ## ⚙️ CLI Options
72
74
 
73
- | Option | Description | Default |
74
- | ---------------------------- | ----------------------------------------------------------- | --------- |
75
- | `-t, --target <url>` | Target API URL **(required)** | — |
76
- | `-m, --mode <mode>` | Operation mode: `record`, `replay`, or `hybrid` | `replay` |
77
- | `-p, --port <number>` | Local server port | `8080` |
78
- | `-d, --dir <path>` | Directory to save tapes | `./tapes` |
79
- | `--record-on-miss <boolean>` | In hybrid mode, save upstream response when tape is missing | `true` |
75
+ ### Serve command
76
+
77
+ Both legacy mode (`tape --target ...`) and explicit serve command (`tape serve --target ...`) are supported.
78
+
79
+ | Option | Description | Default |
80
+ | ---------------------------- | -------------------------------------------------------------- | --------- |
81
+ | `-t, --target <url>` | Target API URL **(required)** | |
82
+ | `-m, --mode <mode>` | Operation mode: `record`, `replay`, or `hybrid` | `replay` |
83
+ | `-p, --port <number>` | Local server port | `8080` |
84
+ | `-d, --dir <path>` | Directory to save tapes | `./tapes` |
85
+ | `--record-on-miss <boolean>` | In hybrid mode, save upstream response when tape is missing | `true` |
86
+ | `--redact-header <headers>` | Comma-separated response header names to redact in saved tapes | — |
87
+
88
+ ### Tape management commands
89
+
90
+ ```bash
91
+ tape tape list --dir ./tapes
92
+ tape tape inspect <hash> --dir ./tapes
93
+ tape tape clear --yes --dir ./tapes
94
+ tape tape prune --older-than 30 --dir ./tapes
95
+ ```
80
96
 
81
97
  ## 📁 Tape Format
82
98
 
package/dist/index.js CHANGED
@@ -24,7 +24,7 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
24
24
  ));
25
25
 
26
26
  // src/index.ts
27
- var import_chalk = __toESM(require("chalk"));
27
+ var import_chalk2 = __toESM(require("chalk"));
28
28
  var import_commander = require("commander");
29
29
  var import_crypto = __toESM(require("crypto"));
30
30
  var import_fs_extra = __toESM(require("fs-extra"));
@@ -32,66 +32,12 @@ var import_http = __toESM(require("http"));
32
32
  var import_http_proxy = __toESM(require("http-proxy"));
33
33
  var import_path = __toESM(require("path"));
34
34
 
35
- // package.json
36
- var package_default = {
37
- name: "@laphilosophia/api-tape",
38
- version: "1.1.0",
39
- description: "Record and Replay HTTP API responses for offline development",
40
- main: "dist/index.js",
41
- bin: {
42
- tape: "./dist/index.js",
43
- "api-tape": "./dist/index.js"
44
- },
45
- files: [
46
- "dist"
47
- ],
48
- publishConfig: {
49
- access: "public"
50
- },
51
- scripts: {
52
- build: "tsup src/index.ts --format cjs --clean",
53
- prepublishOnly: "npm run build",
54
- test: "npm run build && node --test test/**/*.test.cjs"
55
- },
56
- keywords: [
57
- "api",
58
- "mock",
59
- "record",
60
- "replay",
61
- "proxy",
62
- "offline",
63
- "testing",
64
- "http",
65
- "vcr"
66
- ],
67
- author: "Erdem Arslan",
68
- license: "MIT",
69
- repository: {
70
- type: "git",
71
- url: "git+https://github.com/laphilosophia/api-tape.git"
72
- },
73
- homepage: "https://github.com/laphilosophia/api-tape#readme",
74
- bugs: {
75
- url: "https://github.com/laphilosophia/api-tape/issues"
76
- },
77
- type: "commonjs",
78
- dependencies: {
79
- chalk: "^5.6.2",
80
- commander: "^14.0.2",
81
- "fs-extra": "^11.3.3",
82
- "http-proxy": "^1.18.1"
83
- },
84
- devDependencies: {
85
- "@types/fs-extra": "^11.0.4",
86
- "@types/http-proxy": "^1.17.17",
87
- "@types/node": "^25.0.8",
88
- tsup: "^8.5.1",
89
- typescript: "^5.9.3"
90
- }
91
- };
92
-
93
- // src/index.ts
35
+ // src/constants.ts
94
36
  var CURRENT_SCHEMA_VERSION = 1;
37
+
38
+ // src/utils.ts
39
+ var import_chalk = __toESM(require("chalk"));
40
+ var timestamp = () => import_chalk.default.gray(`[${(/* @__PURE__ */ new Date()).toLocaleTimeString()}]`);
95
41
  var parseBooleanOption = (value) => {
96
42
  const normalized = value.toLowerCase().trim();
97
43
  if (normalized === "true") {
@@ -102,144 +48,277 @@ var parseBooleanOption = (value) => {
102
48
  }
103
49
  throw new Error('Option must be either "true" or "false".');
104
50
  };
105
- import_commander.program.name("api-tape").description("Record and Replay HTTP API responses for offline development.").requiredOption("-t, --target <url>", "Target API URL (e.g., https://api.github.com)").option("-p, --port <number>", "Local server port", "8080").option("-m, --mode <mode>", 'Operation mode: "record", "replay", or "hybrid', "replay").option("-d, --dir <path>", "Directory to save tapes", "./tapes").option(
106
- "--record-on-miss <boolean>",
107
- "In hybrid mode, save upstream response when tape is missing",
108
- parseBooleanOption,
109
- true
110
- ).version(package_default.version).parse();
111
- var opts = import_commander.program.opts();
112
- var TARGET_URL = opts.target;
113
- var PORT = parseInt(opts.port, 10);
114
- var MODE = opts.mode;
115
- var TAPES_DIR = import_path.default.resolve(opts.dir);
116
- var RECORD_ON_MISS = opts.recordOnMiss;
117
- var VALID_MODES = ["record", "replay", "hybrid"];
118
- if (!Number.isInteger(PORT) || PORT <= 0) {
119
- console.error(import_chalk.default.red("Invalid port. Please provide a positive integer."));
120
- process.exit(1);
121
- }
122
- if (!VALID_MODES.includes(MODE)) {
123
- console.error(import_chalk.default.red(`Invalid mode: ${MODE}. Expected one of: ${VALID_MODES.join(", ")}`));
124
- process.exit(1);
125
- }
126
- var proxy = import_http_proxy.default.createProxyServer({
127
- target: TARGET_URL,
128
- changeOrigin: true,
129
- selfHandleResponse: true
130
- });
131
- import_fs_extra.default.ensureDirSync(TAPES_DIR);
51
+ var parsePositiveInt = (value, label) => {
52
+ const parsed = parseInt(value, 10);
53
+ if (!Number.isInteger(parsed) || parsed <= 0) {
54
+ throw new Error(`${label} must be a positive integer.`);
55
+ }
56
+ return parsed;
57
+ };
58
+ var parseCsv = (value) => value.split(",").map((item) => item.trim()).filter(Boolean);
59
+
60
+ // src/index.ts
132
61
  var getTapeKey = (req) => {
133
62
  const key = `${req.method}|${req.url}`;
134
63
  return import_crypto.default.createHash("md5").update(key).digest("hex");
135
64
  };
136
- var timestamp = () => import_chalk.default.gray(`[${(/* @__PURE__ */ new Date()).toLocaleTimeString()}]`);
137
- var recordByRequest = /* @__PURE__ */ new WeakMap();
138
- var replayTape = (req, res, tapePath) => {
139
- if (!import_fs_extra.default.existsSync(tapePath)) {
140
- return false;
65
+ var readTape = (tapePath) => import_fs_extra.default.readJsonSync(tapePath);
66
+ var redactHeaders = (headers, redactedHeaders) => {
67
+ if (redactedHeaders.length === 0) {
68
+ return headers;
69
+ }
70
+ const redactedSet = new Set(redactedHeaders.map((header) => header.toLowerCase()));
71
+ const nextHeaders = { ...headers };
72
+ Object.keys(nextHeaders).forEach((key) => {
73
+ if (redactedSet.has(key.toLowerCase())) {
74
+ nextHeaders[key] = "[REDACTED]";
75
+ }
76
+ });
77
+ return nextHeaders;
78
+ };
79
+ var ensureSchemaCompatibility = (record, tapePath) => {
80
+ const schemaVersion = record.schemaVersion ?? 0;
81
+ if (![0, CURRENT_SCHEMA_VERSION].includes(schemaVersion)) {
82
+ throw new Error(`Unsupported tape schema version at ${tapePath}: ${schemaVersion}`);
141
83
  }
142
- try {
143
- const tape = import_fs_extra.default.readJsonSync(tapePath);
144
- const schemaVersion = tape.schemaVersion ?? 0;
145
- if (![0, CURRENT_SCHEMA_VERSION].includes(schemaVersion)) {
84
+ };
85
+ var createTapeRecord = (req, proxyRes, bodyBuffer, redactedHeaders) => ({
86
+ schemaVersion: CURRENT_SCHEMA_VERSION,
87
+ meta: {
88
+ url: req.url,
89
+ method: req.method,
90
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
91
+ },
92
+ statusCode: proxyRes.statusCode || 200,
93
+ headers: redactHeaders(
94
+ proxyRes.headers,
95
+ redactedHeaders
96
+ ),
97
+ body: bodyBuffer.toString("base64")
98
+ });
99
+ var runServe = (opts) => {
100
+ const targetUrl = opts.target;
101
+ const port = parsePositiveInt(opts.port, "Port");
102
+ const mode = opts.mode;
103
+ const tapesDir = import_path.default.resolve(opts.dir);
104
+ const recordOnMiss = opts.recordOnMiss;
105
+ const redactedHeaders = parseCsv(opts.redactHeader);
106
+ const validModes = ["record", "replay", "hybrid"];
107
+ if (!validModes.includes(mode)) {
108
+ throw new Error(`Invalid mode: ${mode}. Expected one of: ${validModes.join(", ")}`);
109
+ }
110
+ import_fs_extra.default.ensureDirSync(tapesDir);
111
+ const proxy = import_http_proxy.default.createProxyServer({
112
+ target: targetUrl,
113
+ changeOrigin: true,
114
+ selfHandleResponse: true
115
+ });
116
+ const recordByRequest = /* @__PURE__ */ new WeakMap();
117
+ const replayTape = (req, res, tapePath) => {
118
+ if (!import_fs_extra.default.existsSync(tapePath)) {
119
+ return false;
120
+ }
121
+ try {
122
+ const tape = readTape(tapePath);
123
+ ensureSchemaCompatibility(tape, tapePath);
124
+ Object.keys(tape.headers).forEach((key) => {
125
+ res.setHeader(key, tape.headers[key]);
126
+ });
127
+ res.setHeader("X-Api-Tape", "Replayed");
128
+ res.writeHead(tape.statusCode);
129
+ res.end(Buffer.from(tape.body, "base64"));
130
+ console.log(`${timestamp()} ${import_chalk2.default.green("\u21BA REPLAY_HIT")} ${req.method} ${req.url}`);
131
+ return true;
132
+ } catch (error) {
133
+ console.error(
134
+ import_chalk2.default.red("Corrupted Tape:"),
135
+ tapePath,
136
+ error instanceof Error ? error.message : "unknown error"
137
+ );
146
138
  res.statusCode = 500;
147
- res.end(`Unsupported tape schema version: ${schemaVersion}`);
148
- console.error(import_chalk.default.red("Unsupported Tape Schema:"), tapePath);
139
+ res.end("Corrupted Tape");
149
140
  return true;
150
141
  }
151
- Object.keys(tape.headers).forEach((key) => {
152
- res.setHeader(key, tape.headers[key]);
142
+ };
143
+ const proxyRequest = (req, res, shouldRecord, logPrefix) => {
144
+ recordByRequest.set(req, shouldRecord);
145
+ console.log(`${timestamp()} ${logPrefix} ${req.method} ${req.url}`);
146
+ proxy.web(req, res, {}, (error) => {
147
+ console.error(import_chalk2.default.red("Proxy Error:"), error.message);
148
+ res.statusCode = 502;
149
+ res.end("Proxy Error");
153
150
  });
154
- res.setHeader("X-Api-Tape", "Replayed");
155
- res.writeHead(tape.statusCode);
156
- res.end(Buffer.from(tape.body, "base64"));
157
- console.log(`${timestamp()} ${import_chalk.default.green("\u21BA REPLAY_HIT")} ${req.method} ${req.url}`);
158
- return true;
159
- } catch {
160
- console.error(import_chalk.default.red("Corrupted Tape:"), tapePath);
161
- res.statusCode = 500;
162
- res.end("Corrupted Tape");
163
- return true;
151
+ };
152
+ const server = import_http.default.createServer((req, res) => {
153
+ const tapeKey = getTapeKey(req);
154
+ const tapePath = import_path.default.join(tapesDir, `${tapeKey}.json`);
155
+ if (mode === "replay") {
156
+ const hit2 = replayTape(req, res, tapePath);
157
+ if (!hit2) {
158
+ console.log(`${timestamp()} ${import_chalk2.default.red("\u2718 REPLAY_MISS")} ${req.method} ${req.url}`);
159
+ res.statusCode = 404;
160
+ res.end(`Tape not found for: ${req.method} ${req.url}`);
161
+ }
162
+ return;
163
+ }
164
+ if (mode === "record") {
165
+ proxyRequest(req, res, true, import_chalk2.default.blue("\u25CF RECORD"));
166
+ return;
167
+ }
168
+ const hit = replayTape(req, res, tapePath);
169
+ if (hit) {
170
+ return;
171
+ }
172
+ console.log(`${timestamp()} ${import_chalk2.default.yellow("\u21E2 REPLAY_MISS")} ${req.method} ${req.url}`);
173
+ proxyRequest(
174
+ req,
175
+ res,
176
+ recordOnMiss,
177
+ recordOnMiss ? import_chalk2.default.magenta("\u21E2 FALLBACK_RECORD") : import_chalk2.default.magenta("\u21E2 FALLBACK_PROXY")
178
+ );
179
+ });
180
+ proxy.on("proxyRes", (proxyRes, req, res) => {
181
+ const bodyChunks = [];
182
+ proxyRes.on("data", (chunk) => bodyChunks.push(chunk));
183
+ proxyRes.on("end", () => {
184
+ const bodyBuffer = Buffer.concat(bodyChunks);
185
+ const shouldRecord = recordByRequest.get(req) ?? true;
186
+ if (shouldRecord) {
187
+ const tapeData = createTapeRecord(req, proxyRes, bodyBuffer, redactedHeaders);
188
+ const tapeKey = getTapeKey(req);
189
+ const tapePath = import_path.default.join(tapesDir, `${tapeKey}.json`);
190
+ import_fs_extra.default.writeJsonSync(tapePath, tapeData, { spaces: 2 });
191
+ console.log(`${timestamp()} ${import_chalk2.default.cyan("\u{1F4BE} SAVED")} ${req.method} ${req.url}`);
192
+ }
193
+ Object.keys(proxyRes.headers).forEach((key) => {
194
+ res.setHeader(key, proxyRes.headers[key]);
195
+ });
196
+ res.writeHead(proxyRes.statusCode || 200);
197
+ res.end(bodyBuffer);
198
+ });
199
+ });
200
+ console.log(import_chalk2.default.bold(`
201
+ \u{1F4FC} API Tape Running`));
202
+ console.log(
203
+ ` ${import_chalk2.default.dim("Mode:")} ${mode === "record" ? import_chalk2.default.red("\u25CF RECORD") : mode === "hybrid" ? import_chalk2.default.yellow("\u21E2 HYBRID") : import_chalk2.default.green("\u21BA REPLAY")}`
204
+ );
205
+ console.log(` ${import_chalk2.default.dim("Target:")} ${targetUrl}`);
206
+ console.log(` ${import_chalk2.default.dim("Port:")} http://localhost:${port}`);
207
+ console.log(` ${import_chalk2.default.dim("Dir:")} ${tapesDir}`);
208
+ if (mode === "hybrid") {
209
+ console.log(` ${import_chalk2.default.dim("Record Miss:")} ${recordOnMiss}`);
164
210
  }
211
+ if (redactedHeaders.length > 0) {
212
+ console.log(` ${import_chalk2.default.dim("Redact Headers:")} ${redactedHeaders.join(", ")}`);
213
+ }
214
+ console.log("");
215
+ server.listen(port);
165
216
  };
166
- var proxyRequest = (req, res, shouldRecord, logPrefix) => {
167
- recordByRequest.set(req, shouldRecord);
168
- console.log(`${timestamp()} ${logPrefix} ${req.method} ${req.url}`);
169
- proxy.web(req, res, {}, (error) => {
170
- console.error(import_chalk.default.red("Proxy Error:"), error.message);
171
- res.statusCode = 502;
172
- res.end("Proxy Error");
217
+ var listTapes = (dir) => {
218
+ const tapesDir = import_path.default.resolve(dir);
219
+ import_fs_extra.default.ensureDirSync(tapesDir);
220
+ const files = import_fs_extra.default.readdirSync(tapesDir).filter((file) => file.endsWith(".json")).sort();
221
+ if (files.length === 0) {
222
+ console.log("No tapes found.");
223
+ return;
224
+ }
225
+ files.forEach((file) => {
226
+ const tapePath = import_path.default.join(tapesDir, file);
227
+ const tape = readTape(tapePath);
228
+ ensureSchemaCompatibility(tape, tapePath);
229
+ const method = tape.meta.method || "UNKNOWN";
230
+ const route = tape.meta.url || "(unknown-url)";
231
+ const time = tape.meta.timestamp || "(unknown-time)";
232
+ console.log(`${import_path.default.basename(file, ".json")} ${method} ${route} ${time}`);
173
233
  });
174
234
  };
175
- var server = import_http.default.createServer((req, res) => {
176
- const tapeKey = getTapeKey(req);
177
- const tapePath = import_path.default.join(TAPES_DIR, `${tapeKey}.json`);
178
- if (MODE === "replay") {
179
- const hit2 = replayTape(req, res, tapePath);
180
- if (!hit2) {
181
- console.log(`${timestamp()} ${import_chalk.default.red("\u2718 REPLAY_MISS")} ${req.method} ${req.url}`);
182
- res.statusCode = 404;
183
- res.end(`Tape not found for: ${req.method} ${req.url}`);
184
- }
185
- return;
235
+ var inspectTape = (dir, tapeId) => {
236
+ const tapesDir = import_path.default.resolve(dir);
237
+ const tapePath = import_path.default.join(tapesDir, `${tapeId}.json`);
238
+ if (!import_fs_extra.default.existsSync(tapePath)) {
239
+ throw new Error(`Tape not found: ${tapeId}`);
186
240
  }
187
- if (MODE === "record") {
188
- proxyRequest(req, res, true, import_chalk.default.blue("\u25CF RECORD"));
189
- return;
241
+ const tape = readTape(tapePath);
242
+ ensureSchemaCompatibility(tape, tapePath);
243
+ const preview = {
244
+ id: tapeId,
245
+ schemaVersion: tape.schemaVersion ?? 0,
246
+ meta: tape.meta,
247
+ statusCode: tape.statusCode,
248
+ headers: tape.headers,
249
+ bodyBytes: Buffer.from(tape.body, "base64").byteLength
250
+ };
251
+ console.log(JSON.stringify(preview, null, 2));
252
+ };
253
+ var clearTapes = (dir, confirmed) => {
254
+ if (!confirmed) {
255
+ throw new Error("Refusing to clear tapes without --yes flag.");
190
256
  }
191
- const hit = replayTape(req, res, tapePath);
192
- if (hit) {
257
+ const tapesDir = import_path.default.resolve(dir);
258
+ import_fs_extra.default.ensureDirSync(tapesDir);
259
+ const files = import_fs_extra.default.readdirSync(tapesDir).filter((file) => file.endsWith(".json"));
260
+ files.forEach((file) => {
261
+ import_fs_extra.default.removeSync(import_path.default.join(tapesDir, file));
262
+ });
263
+ console.log(`Cleared ${files.length} tape file(s).`);
264
+ };
265
+ var pruneTapes = (dir, olderThanDays) => {
266
+ const days = parsePositiveInt(olderThanDays, "older-than");
267
+ const cutoff = Date.now() - days * 24 * 60 * 60 * 1e3;
268
+ const tapesDir = import_path.default.resolve(dir);
269
+ import_fs_extra.default.ensureDirSync(tapesDir);
270
+ const files = import_fs_extra.default.readdirSync(tapesDir).filter((file) => file.endsWith(".json"));
271
+ let removed = 0;
272
+ files.forEach((file) => {
273
+ const target = import_path.default.join(tapesDir, file);
274
+ const stat = import_fs_extra.default.statSync(target);
275
+ if (stat.mtimeMs < cutoff) {
276
+ import_fs_extra.default.removeSync(target);
277
+ removed += 1;
278
+ }
279
+ });
280
+ console.log(`Pruned ${removed} tape file(s) older than ${days} day(s).`);
281
+ };
282
+ var addServeOptions = (command) => command.requiredOption("-t, --target <url>", "Target API URL (e.g., https://api.github.com)").option("-p, --port <number>", "Local server port", "8080").option("-m, --mode <mode>", 'Operation mode: "record", "replay", or "hybrid"', "replay").option("-d, --dir <path>", "Directory to save tapes", "./tapes").option(
283
+ "--record-on-miss <boolean>",
284
+ "In hybrid mode, save upstream response when tape is missing",
285
+ parseBooleanOption,
286
+ true
287
+ ).option(
288
+ "--redact-header <headers>",
289
+ "Comma-separated response header names to redact before saving",
290
+ ""
291
+ );
292
+ var run = () => {
293
+ const argv = process.argv;
294
+ const hasSubCommand = argv.length > 2 && ["serve", "tape"].includes(argv[2]);
295
+ if (!hasSubCommand) {
296
+ const legacy = addServeOptions(new import_commander.Command()).name("api-tape").description("Record and Replay HTTP API responses for offline development.").version("1.2.0").action((options) => {
297
+ runServe(options);
298
+ });
299
+ legacy.parse(argv);
193
300
  return;
194
301
  }
195
- console.log(`${timestamp()} ${import_chalk.default.yellow("\u21E2 REPLAY_MISS")} ${req.method} ${req.url}`);
196
- proxyRequest(
197
- req,
198
- res,
199
- RECORD_ON_MISS,
200
- RECORD_ON_MISS ? import_chalk.default.magenta("\u21E2 FALLBACK_RECORD") : import_chalk.default.magenta("\u21E2 FALLBACK_PROXY")
201
- );
202
- });
203
- proxy.on("proxyRes", (proxyRes, req, res) => {
204
- const bodyChunks = [];
205
- proxyRes.on("data", (chunk) => bodyChunks.push(chunk));
206
- proxyRes.on("end", () => {
207
- const bodyBuffer = Buffer.concat(bodyChunks);
208
- const tapeData = {
209
- schemaVersion: CURRENT_SCHEMA_VERSION,
210
- meta: {
211
- url: req.url,
212
- method: req.method,
213
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
214
- },
215
- statusCode: proxyRes.statusCode,
216
- headers: proxyRes.headers,
217
- body: bodyBuffer.toString("base64")
218
- };
219
- const shouldRecord = recordByRequest.get(req) ?? true;
220
- if (shouldRecord) {
221
- const tapeKey = getTapeKey(req);
222
- const tapePath = import_path.default.join(TAPES_DIR, `${tapeKey}.json`);
223
- import_fs_extra.default.writeJsonSync(tapePath, tapeData, { spaces: 2 });
224
- console.log(`${timestamp()} ${import_chalk.default.cyan("\u{1F4BE} SAVED")} ${req.method} ${req.url}`);
302
+ const program = new import_commander.Command();
303
+ program.name("api-tape").description("Record and Replay HTTP API responses for offline development.").version("1.2.0");
304
+ addServeOptions(program.command("serve").description("Run API Tape proxy server")).action(
305
+ (options) => {
306
+ runServe(options);
225
307
  }
226
- Object.keys(proxyRes.headers).forEach((key) => {
227
- res.setHeader(key, proxyRes.headers[key]);
228
- });
229
- res.writeHead(proxyRes.statusCode || 200);
230
- res.end(bodyBuffer);
308
+ );
309
+ const tape = program.command("tape").description("Manage recorded tape files");
310
+ tape.command("list").description("List all recorded tapes").option("-d, --dir <path>", "Directory to save tapes", "./tapes").action((options) => {
311
+ listTapes(options.dir);
231
312
  });
232
- });
233
- console.log(import_chalk.default.bold(`
234
- \u{1F4FC} API Tape Running`));
235
- console.log(
236
- ` ${import_chalk.default.dim("Mode:")} ${MODE === "record" ? import_chalk.default.red("\u25CF RECORD") : MODE === "hybrid" ? import_chalk.default.yellow("\u21E2 HYBRID") : import_chalk.default.green("\u21BA REPLAY")}`
237
- );
238
- console.log(` ${import_chalk.default.dim("Target:")} ${TARGET_URL}`);
239
- console.log(` ${import_chalk.default.dim("Port:")} http://localhost:${PORT}`);
240
- console.log(` ${import_chalk.default.dim("Dir:")} ${TAPES_DIR}`);
241
- if (MODE === "hybrid") {
242
- console.log(` ${import_chalk.default.dim("Record Miss:")} ${RECORD_ON_MISS}`);
243
- }
244
- console.log("");
245
- server.listen(PORT);
313
+ tape.command("inspect <id>").description("Inspect a recorded tape by hash id").option("-d, --dir <path>", "Directory to save tapes", "./tapes").action((id, options) => {
314
+ inspectTape(options.dir, id);
315
+ });
316
+ tape.command("clear").description("Delete all tape files from directory").option("-d, --dir <path>", "Directory to save tapes", "./tapes").option("--yes", "Confirm destructive deletion", false).action((options) => {
317
+ clearTapes(options.dir, options.yes);
318
+ });
319
+ tape.command("prune").description("Delete tape files older than N days").requiredOption("--older-than <days>", "Remove files older than N days").option("-d, --dir <path>", "Directory to save tapes", "./tapes").action((options) => {
320
+ pruneTapes(options.dir, options.olderThan);
321
+ });
322
+ program.parse(argv);
323
+ };
324
+ run();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@laphilosophia/api-tape",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Record and Replay HTTP API responses for offline development",
5
5
  "main": "dist/index.js",
6
6
  "bin": {