counterfact 2.8.1 → 2.10.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 (48) hide show
  1. package/README.md +36 -13
  2. package/bin/README.md +39 -14
  3. package/bin/counterfact.js +18 -547
  4. package/bin/ts-loader.mjs +1 -0
  5. package/dist/api-runner.js +202 -0
  6. package/dist/app.js +72 -138
  7. package/dist/cli/banner.js +81 -0
  8. package/dist/cli/check-for-updates.js +45 -0
  9. package/dist/cli/run.js +304 -0
  10. package/dist/cli/telemetry.js +50 -0
  11. package/dist/migrate/paths-to-routes.js +1 -0
  12. package/dist/migrate/update-route-types.js +2 -1
  13. package/dist/msw.js +78 -0
  14. package/dist/repl/raw-http-client.js +3 -1
  15. package/dist/repl/repl.js +228 -60
  16. package/dist/server/counterfact-types/generic-response-builder.ts +1 -2
  17. package/dist/server/counterfact-types/open-api-parameters.ts +3 -0
  18. package/dist/server/determine-module-kind.js +1 -0
  19. package/dist/server/dispatcher.js +45 -2
  20. package/dist/server/file-discovery.js +1 -0
  21. package/dist/server/module-loader.js +8 -0
  22. package/dist/server/request-validator.js +42 -1
  23. package/dist/server/transpiler.js +1 -0
  24. package/dist/server/{admin-api-middleware.js → web-server/admin-api-middleware.js} +19 -9
  25. package/dist/server/web-server/create-koa-app.js +68 -0
  26. package/dist/server/web-server/openapi-middleware.js +34 -0
  27. package/dist/server/{koa-middleware.js → web-server/routes-middleware.js} +11 -8
  28. package/dist/typescript-generator/code-generator.js +2 -1
  29. package/dist/typescript-generator/coder.js +4 -2
  30. package/dist/typescript-generator/operation-coder.js +4 -4
  31. package/dist/typescript-generator/operation-type-coder.js +15 -14
  32. package/dist/typescript-generator/parameter-export-type-coder.js +2 -2
  33. package/dist/typescript-generator/parameters-type-coder.js +3 -3
  34. package/dist/typescript-generator/prune.js +1 -0
  35. package/dist/typescript-generator/repository.js +1 -0
  36. package/dist/typescript-generator/response-type-coder.js +7 -6
  37. package/dist/typescript-generator/responses-type-coder.js +3 -3
  38. package/dist/typescript-generator/scenario-file-generator.js +1 -0
  39. package/dist/typescript-generator/schema-coder.js +2 -2
  40. package/dist/typescript-generator/schema-type-coder.js +7 -5
  41. package/dist/typescript-generator/script.js +58 -3
  42. package/dist/util/ensure-directory-exists.js +1 -0
  43. package/dist/util/load-config-file.js +2 -2
  44. package/dist/util/read-file.js +16 -2
  45. package/dist/util/runtime-can-execute-erasable-ts.js +1 -0
  46. package/package.json +3 -2
  47. package/dist/server/create-koa-app.js +0 -65
  48. package/dist/server/openapi-middleware.js +0 -48
@@ -0,0 +1,202 @@
1
+ import { rm } from "node:fs/promises";
2
+ import { ContextRegistry } from "./server/context-registry.js";
3
+ import { Dispatcher } from "./server/dispatcher.js";
4
+ import { loadOpenApiDocument } from "./server/load-openapi-document.js";
5
+ import { ModuleLoader } from "./server/module-loader.js";
6
+ import { Registry } from "./server/registry.js";
7
+ import { ScenarioRegistry } from "./server/scenario-registry.js";
8
+ import { Transpiler } from "./server/transpiler.js";
9
+ import { CodeGenerator } from "./typescript-generator/code-generator.js";
10
+ import { ScenarioFileGenerator } from "./typescript-generator/scenario-file-generator.js";
11
+ import { pathJoin } from "./util/forward-slash-path.js";
12
+ import { runtimeCanExecuteErasableTs } from "./util/runtime-can-execute-erasable-ts.js";
13
+ /**
14
+ * Encapsulates the creation and lifecycle management of all Counterfact
15
+ * sub-systems for a single API specification.
16
+ *
17
+ * Use the static {@link ApiRunner.create} factory method to construct an
18
+ * instance — the constructor requires async initialisation (loading the
19
+ * OpenAPI document and probing for native TypeScript support) that cannot
20
+ * be done in a synchronous constructor.
21
+ *
22
+ * Each sub-system is exposed as a public property so callers can interact
23
+ * with individual components directly when needed. The composite methods
24
+ * {@link generate}, {@link load}, {@link watch}, and {@link stopWatching}
25
+ * coordinate across multiple sub-systems and encapsulate the conditional
26
+ * logic driven by the configuration.
27
+ */
28
+ export class ApiRunner {
29
+ /** Stores loaded route handlers, keyed by path. */
30
+ registry;
31
+ /** Stores context objects per route path and dispatches change events. */
32
+ contextRegistry;
33
+ /** Registry of loaded scenario modules (used by the REPL). */
34
+ scenarioRegistry;
35
+ /** Generates `types/_.context.ts` and the default `scenarios/index.ts`. */
36
+ scenarioFileGenerator;
37
+ /** Reads the OpenAPI spec and writes TypeScript route/type stub files. */
38
+ codeGenerator;
39
+ /** Routes incoming HTTP requests to the matching handler in `registry`. */
40
+ dispatcher;
41
+ /**
42
+ * Compiles TypeScript route files to JavaScript when the runtime cannot
43
+ * execute TypeScript natively.
44
+ */
45
+ transpiler;
46
+ /**
47
+ * Imports compiled route/context/scenario modules and hot-reloads them on
48
+ * file-system changes.
49
+ */
50
+ moduleLoader;
51
+ /**
52
+ * The loaded OpenAPI document, or `undefined` when `config.openApiPath`
53
+ * is `"_"` (spec-less mode).
54
+ */
55
+ openApiDocument;
56
+ /** `true` when the current Node.js runtime can execute TypeScript natively. */
57
+ nativeTs;
58
+ /** Path or URL to the OpenAPI document for this runner. */
59
+ openApiPath;
60
+ /** URL prefix that this runner intercepts (default `""`). */
61
+ prefix;
62
+ /**
63
+ * Optional group name that places generated code in a subdirectory.
64
+ * Defaults to `""` (no subdirectory).
65
+ */
66
+ group;
67
+ /**
68
+ * The subdirectory path segment derived from {@link group}.
69
+ * Returns `""` when `group` is empty, otherwise `"/${group}"`.
70
+ */
71
+ get subdirectory() {
72
+ return this.group ? `/${this.group}` : "";
73
+ }
74
+ config;
75
+ constructor(config, nativeTs, openApiDocument, group) {
76
+ this.group = group;
77
+ const modulesPath = this.group
78
+ ? pathJoin(config.basePath, this.group)
79
+ : config.basePath;
80
+ const compiledPathsDirectory = pathJoin(modulesPath, nativeTs ? "routes" : ".cache");
81
+ this.config = config;
82
+ this.nativeTs = nativeTs;
83
+ this.openApiDocument = openApiDocument;
84
+ this.openApiPath = config.openApiPath;
85
+ this.prefix = config.prefix;
86
+ this.registry = new Registry();
87
+ this.contextRegistry = new ContextRegistry();
88
+ this.scenarioRegistry = new ScenarioRegistry();
89
+ this.scenarioFileGenerator = new ScenarioFileGenerator(modulesPath);
90
+ this.codeGenerator = new CodeGenerator(this.openApiPath, config.basePath + this.subdirectory, config.generate);
91
+ this.dispatcher = new Dispatcher(this.registry, this.contextRegistry, openApiDocument, config);
92
+ this.transpiler = new Transpiler(pathJoin(modulesPath, "routes"), compiledPathsDirectory, "commonjs");
93
+ this.moduleLoader = new ModuleLoader(compiledPathsDirectory, this.registry, this.contextRegistry, pathJoin(modulesPath, "scenarios"), this.scenarioRegistry);
94
+ }
95
+ /**
96
+ * Creates and returns a fully initialised `ApiRunner`.
97
+ *
98
+ * Probes for native TypeScript support, optionally cleans the compiled
99
+ * output directory, and loads the OpenAPI document before constructing
100
+ * the runner.
101
+ *
102
+ * @param config - Runtime configuration for this runner instance.
103
+ * @param group - Optional group name placing generated code in a subdirectory (default `""`).
104
+ */
105
+ static async create(config, group = "") {
106
+ const nativeTs = await runtimeCanExecuteErasableTs();
107
+ const modulesPath = group
108
+ ? pathJoin(config.basePath, group)
109
+ : config.basePath;
110
+ const compiledPathsDirectory = pathJoin(modulesPath, nativeTs ? "routes" : ".cache");
111
+ if (!nativeTs) {
112
+ await rm(compiledPathsDirectory, { force: true, recursive: true });
113
+ }
114
+ const openApiDocument = config.openApiPath === "_"
115
+ ? undefined
116
+ : await loadOpenApiDocument(config.openApiPath);
117
+ return new ApiRunner(config, nativeTs, openApiDocument, group);
118
+ }
119
+ /**
120
+ * Generates TypeScript route stubs and type files from the OpenAPI spec.
121
+ *
122
+ * Respects the `config.generate.routes` and `config.generate.types` flags:
123
+ * - Routes and types are only generated when `config.openApiPath` is not `"_"`.
124
+ * - The scenario context type file is always generated when
125
+ * `config.generate.types` is `true`, even without a spec.
126
+ */
127
+ async generate() {
128
+ if (this.config.openApiPath !== "_" &&
129
+ (this.config.generate.routes || this.config.generate.types)) {
130
+ await this.codeGenerator.generate();
131
+ }
132
+ if (this.config.generate.types) {
133
+ await this.scenarioFileGenerator.generate();
134
+ }
135
+ }
136
+ /**
137
+ * Loads all compiled route, context, and scenario modules into the
138
+ * appropriate registries.
139
+ */
140
+ async load() {
141
+ await this.moduleLoader.load();
142
+ }
143
+ /**
144
+ * Starts watching the OpenAPI spec and scenario context files for changes
145
+ * and re-generates the corresponding TypeScript files on each change.
146
+ *
147
+ * - Re-generates route stubs when the spec changes (when
148
+ * `config.watch.routes` or `config.watch.types` is `true`).
149
+ * - Re-generates `types/_.context.ts` when a `_.context.ts` file changes
150
+ * (when `config.watch.types` is `true`).
151
+ */
152
+ async watch() {
153
+ if (this.config.openApiPath !== "_" &&
154
+ (this.config.watch.routes || this.config.watch.types)) {
155
+ await this.codeGenerator.watch();
156
+ }
157
+ if (this.config.watch.types) {
158
+ await this.scenarioFileGenerator.watch();
159
+ }
160
+ }
161
+ /**
162
+ * Starts the server-related sub-systems based on the supplied options.
163
+ *
164
+ * When `startServer` is `true`:
165
+ * - Watches the OpenAPI document for live reloads.
166
+ * - Transpiles TypeScript route files (when the runtime does not support
167
+ * native TypeScript execution).
168
+ * - Loads all compiled modules into their registries.
169
+ * - Watches compiled modules for hot-reload.
170
+ *
171
+ * When `buildCache` is `true` (and `startServer` is `false`):
172
+ * - Runs the transpiler once to build the compiled-output cache, then stops.
173
+ *
174
+ * @param options - Subset of the runtime config that governs startup behaviour.
175
+ */
176
+ async start(options) {
177
+ const { startServer, buildCache } = options;
178
+ if (startServer) {
179
+ await this.openApiDocument?.watch();
180
+ if (!this.nativeTs) {
181
+ await this.transpiler.watch();
182
+ }
183
+ await this.moduleLoader.load();
184
+ await this.moduleLoader.watch();
185
+ }
186
+ else if (buildCache) {
187
+ // Transpile once to populate the cache, then immediately stop watching.
188
+ await this.transpiler.watch();
189
+ await this.transpiler.stopWatching();
190
+ }
191
+ }
192
+ /**
193
+ * Stops all active file-system watchers across every sub-system.
194
+ */
195
+ async stopWatching() {
196
+ await this.codeGenerator.stopWatching();
197
+ await this.scenarioFileGenerator.stopWatching();
198
+ await this.transpiler.stopWatching();
199
+ await this.moduleLoader.stopWatching();
200
+ await this.openApiDocument?.stopWatching();
201
+ }
202
+ }
package/dist/app.js CHANGED
@@ -1,20 +1,10 @@
1
- import fs, { rm } from "node:fs/promises";
2
1
  import { createHttpTerminator } from "http-terminator";
2
+ import { ApiRunner } from "./api-runner.js";
3
3
  import { startRepl as startReplServer } from "./repl/repl.js";
4
4
  import { createRouteFunction } from "./repl/route-builder.js";
5
- import { ContextRegistry } from "./server/context-registry.js";
6
- import { createKoaApp } from "./server/create-koa-app.js";
7
- import { Dispatcher } from "./server/dispatcher.js";
8
- import { loadOpenApiDocument } from "./server/load-openapi-document.js";
9
- import { ModuleLoader } from "./server/module-loader.js";
10
- import { Registry } from "./server/registry.js";
11
- import { ScenarioRegistry } from "./server/scenario-registry.js";
12
- import { Transpiler } from "./server/transpiler.js";
13
- import { CodeGenerator } from "./typescript-generator/code-generator.js";
14
- import { ScenarioFileGenerator } from "./typescript-generator/scenario-file-generator.js";
15
- import { runtimeCanExecuteErasableTs } from "./util/runtime-can-execute-erasable-ts.js";
16
- import { pathJoin } from "./util/forward-slash-path.js";
5
+ import { createKoaApp } from "./server/web-server/create-koa-app.js";
17
6
  export { loadOpenApiDocument } from "./server/load-openapi-document.js";
7
+ export { createMswHandlers, handleMswRequest, } from "./msw.js";
18
8
  export async function runStartupScenario(scenarioRegistry, contextRegistry, config, openApiDocument) {
19
9
  const indexModule = scenarioRegistry.getModule("index");
20
10
  if (!indexModule || typeof indexModule["startup"] !== "function") {
@@ -28,86 +18,59 @@ export async function runStartupScenario(scenarioRegistry, contextRegistry, conf
28
18
  };
29
19
  await indexModule["startup"](scenario$);
30
20
  }
31
- const allowedMethods = [
32
- "all",
33
- "head",
34
- "get",
35
- "post",
36
- "put",
37
- "delete",
38
- "patch",
39
- "options",
40
- ];
41
- const mswHandlers = {};
42
21
  /**
43
- * Dispatches a single MSW (Mock Service Worker) intercepted request to the
44
- * matching Counterfact route handler registered via {@link createMswHandlers}.
22
+ * Normalises the spec configuration to an array.
45
23
  *
46
- * @param request - The intercepted request, including the HTTP method, path,
47
- * headers, query, body, and a `rawPath` that preserves the original URL
48
- * before base-path stripping.
49
- * @returns The response produced by the matching handler, or a 404 object when
50
- * no handler has been registered for the given method and path.
24
+ * When `specs` is provided it is returned as-is. When it is omitted, a
25
+ * single-entry array is constructed from `config.openApiPath`,
26
+ * `config.prefix`, and `group = ""` so that the rest of the code never
27
+ * needs to branch on single-vs-multiple specs.
51
28
  */
52
- export async function handleMswRequest(request) {
53
- const { method, rawPath } = request;
54
- const handler = mswHandlers[`${method}:${rawPath}`];
55
- if (handler) {
56
- return handler(request);
29
+ function normalizeSpecs(config, specs) {
30
+ if (specs !== undefined) {
31
+ return specs;
57
32
  }
58
- console.warn(`No handler found for ${method} ${rawPath}`);
59
- return { error: `No handler found for ${method} ${rawPath}`, status: 404 };
33
+ return [{ source: config.openApiPath, prefix: config.prefix, group: "" }];
60
34
  }
61
- /**
62
- * Loads an OpenAPI document, registers all routes from it as MSW handlers, and
63
- * returns the list of registered routes so callers (e.g. Vitest Browser mode)
64
- * can mount them on their own request-interception layer.
65
- *
66
- * @param config - Counterfact configuration; `openApiPath` and `basePath` are
67
- * the most important fields for this function.
68
- * @param ModuleLoaderClass - Injectable module-loader constructor, primarily
69
- * used in tests to substitute a test-friendly implementation.
70
- * @returns An array of `{ method, path }` objects describing every registered
71
- * MSW handler.
72
- */
73
- export async function createMswHandlers(config, ModuleLoaderClass = ModuleLoader) {
74
- // TODO: For some reason the Vitest Custom Commands needed by Vitest Browser mode fail on fs.readFile when they are called from the nested loadOpenApiDocument function.
75
- // If we "pre-read" the file here it works. This is a workaround to avoid the issue.
76
- await fs.readFile(config.openApiPath);
77
- const openApiDocument = await loadOpenApiDocument(config.openApiPath);
78
- const modulesPath = config.basePath;
79
- const compiledPathsDirectory = pathJoin(modulesPath, ".cache");
80
- const registry = new Registry();
81
- const contextRegistry = new ContextRegistry();
82
- const dispatcher = new Dispatcher(registry, contextRegistry, openApiDocument, config);
83
- const moduleLoader = new ModuleLoaderClass(compiledPathsDirectory, registry, contextRegistry);
84
- await moduleLoader.load();
85
- const routes = registry.routes;
86
- const handlers = routes.flatMap((route) => {
87
- const { methods, path } = route;
88
- return Object.keys(methods)
89
- .filter((method) => allowedMethods.includes(method.toLowerCase()))
90
- .map((method) => {
91
- const lowerMethod = method.toLowerCase();
92
- const apiPath = `${openApiDocument.basePath ?? ""}${path.replaceAll("{", ":").replaceAll("}", "")}`;
93
- const handler = async (request) => {
94
- return await dispatcher.request(request);
95
- };
96
- mswHandlers[`${method}:${apiPath}`] = handler;
97
- return { method: lowerMethod, path: apiPath };
98
- });
99
- });
100
- return handlers;
35
+ function validateSpecGroups(specs) {
36
+ if (specs.length <= 1) {
37
+ return;
38
+ }
39
+ const invalidSpecNumbers = specs
40
+ .map((spec, index) => ({ group: spec.group.trim(), index }))
41
+ .filter(({ group }) => group === "")
42
+ .map(({ index }) => String(index + 1));
43
+ if (invalidSpecNumbers.length === 0) {
44
+ const seenGroups = new Set();
45
+ const duplicateGroupNames = new Set();
46
+ for (const spec of specs) {
47
+ const group = spec.group.trim();
48
+ if (seenGroups.has(group)) {
49
+ duplicateGroupNames.add(group);
50
+ continue;
51
+ }
52
+ seenGroups.add(group);
53
+ }
54
+ if (duplicateGroupNames.size === 0) {
55
+ return;
56
+ }
57
+ throw new Error(`Each spec must define a unique group when multiple APIs are configured (duplicate groups: ${[...duplicateGroupNames].join(", ")}).`);
58
+ }
59
+ throw new Error(`Each spec must define a non-empty group when multiple APIs are configured (invalid spec entries: ${invalidSpecNumbers.join(", ")}).`);
101
60
  }
102
61
  /**
103
62
  * Creates and configures a full Counterfact server instance.
104
63
  *
105
- * Sets up the route registry, context registry, scenario registry, code
106
- * generator, transpiler, module loader, Koa application, and OpenAPI watcher.
107
- * The returned object exposes handles for starting the server, stopping it, and
108
- * launching the interactive REPL.
64
+ * Supports one or more API specifications. Each spec produces its own
65
+ * {@link ApiRunner}. When `specs` is omitted a single runner is created from
66
+ * `config.openApiPath` and `config.prefix`.
67
+ *
68
+ * The returned object exposes handles for starting the server, stopping it,
69
+ * and launching the interactive REPL.
109
70
  *
110
71
  * @param config - Runtime configuration (port, paths, feature flags, etc.).
72
+ * @param specs - Optional array of spec entries. Omit to use a single spec
73
+ * derived from `config.openApiPath` and `config.prefix`.
111
74
  * @returns An object containing the configured sub-systems and two entry-point
112
75
  * functions:
113
76
  * - `start(options)` — generates/watches code and optionally starts the HTTP
@@ -115,53 +78,23 @@ export async function createMswHandlers(config, ModuleLoaderClass = ModuleLoader
115
78
  * - `startRepl()` — launches the interactive Node.js REPL connected to the
116
79
  * live server state.
117
80
  */
118
- export async function counterfact(config) {
119
- const modulesPath = config.basePath;
120
- const nativeTs = await runtimeCanExecuteErasableTs();
121
- const compiledPathsDirectory = pathJoin(modulesPath, nativeTs ? "routes" : ".cache");
122
- if (!nativeTs) {
123
- await rm(compiledPathsDirectory, { force: true, recursive: true });
124
- }
125
- const registry = new Registry();
126
- const contextRegistry = new ContextRegistry();
127
- const scenarioRegistry = new ScenarioRegistry();
128
- const scenarioFileGenerator = new ScenarioFileGenerator(modulesPath);
129
- const codeGenerator = new CodeGenerator(config.openApiPath, config.basePath, config.generate);
130
- const openApiDocument = config.openApiPath === "_"
131
- ? undefined
132
- : await loadOpenApiDocument(config.openApiPath);
133
- const dispatcher = new Dispatcher(registry, contextRegistry, openApiDocument, config);
134
- const transpiler = new Transpiler(pathJoin(modulesPath, "routes"), compiledPathsDirectory, "commonjs");
135
- const moduleLoader = new ModuleLoader(compiledPathsDirectory, registry, contextRegistry, pathJoin(modulesPath, "scenarios"), scenarioRegistry);
81
+ export async function counterfact(config, specs) {
82
+ const normalizedSpecs = normalizeSpecs({ openApiPath: config.openApiPath, prefix: config.prefix }, specs);
83
+ validateSpecGroups(normalizedSpecs);
84
+ const runners = await Promise.all(normalizedSpecs.map((spec) => ApiRunner.create({ ...config, openApiPath: spec.source, prefix: spec.prefix }, spec.group)));
136
85
  const koaApp = createKoaApp({
86
+ runners,
137
87
  config,
138
- contextRegistry,
139
- dispatcher,
140
- registry,
141
88
  });
89
+ // The REPL is configured using the first runner.
90
+ const primaryRunner = runners[0];
142
91
  async function start(options) {
143
- const { generate, startServer, watch, buildCache } = options;
144
- if (config.openApiPath !== "_" && (generate.routes || generate.types)) {
145
- await codeGenerator.generate();
146
- }
147
- if (generate.types) {
148
- await scenarioFileGenerator.generate();
149
- }
150
- if (config.openApiPath !== "_" && (watch.routes || watch.types)) {
151
- await codeGenerator.watch();
152
- }
153
- if (watch.types) {
154
- await scenarioFileGenerator.watch();
155
- }
92
+ await Promise.all(runners.map((runner) => runner.generate()));
93
+ await Promise.all(runners.map((runner) => runner.watch()));
94
+ await Promise.all(runners.map((runner) => runner.start(options)));
156
95
  let httpTerminator;
157
- if (startServer) {
158
- await openApiDocument?.watch();
159
- if (!nativeTs) {
160
- await transpiler.watch();
161
- }
162
- await moduleLoader.load();
163
- await moduleLoader.watch();
164
- await runStartupScenario(scenarioRegistry, contextRegistry, config, openApiDocument);
96
+ if (options.startServer) {
97
+ await runStartupScenario(primaryRunner.scenarioRegistry, primaryRunner.contextRegistry, { port: config.port }, primaryRunner.openApiDocument);
165
98
  const server = koaApp.listen({
166
99
  port: config.port,
167
100
  });
@@ -169,28 +102,29 @@ export async function counterfact(config) {
169
102
  server,
170
103
  });
171
104
  }
172
- else if (buildCache) {
173
- // If we are not starting the server, we still want to transpile and load modules
174
- await transpiler.watch();
175
- await transpiler.stopWatching();
176
- }
177
105
  return {
178
106
  async stop() {
179
- await codeGenerator.stopWatching();
180
- await scenarioFileGenerator.stopWatching();
181
- await transpiler.stopWatching();
182
- await moduleLoader.stopWatching();
183
- await openApiDocument?.stopWatching();
107
+ await Promise.all(runners.map((runner) => runner.stopWatching()));
184
108
  await httpTerminator?.terminate();
185
109
  },
186
110
  };
187
111
  }
188
112
  return {
189
- contextRegistry,
113
+ contextRegistry: primaryRunner.contextRegistry,
190
114
  koaApp,
191
- registry,
115
+ registry: primaryRunner.registry,
192
116
  start,
193
- startRepl: () => startReplServer(contextRegistry, registry, config, undefined, // use the default print function (stdout)
194
- openApiDocument, scenarioRegistry),
117
+ startRepl: () => startReplServer(primaryRunner.contextRegistry, primaryRunner.registry, {
118
+ port: config.port,
119
+ proxyPaths: config.proxyPaths,
120
+ proxyUrl: config.proxyUrl,
121
+ }, undefined, // use the default print function (stdout)
122
+ primaryRunner.openApiDocument, primaryRunner.scenarioRegistry, runners.map((runner) => ({
123
+ contextRegistry: runner.contextRegistry,
124
+ group: runner.group,
125
+ openApiDocument: runner.openApiDocument,
126
+ registry: runner.registry,
127
+ scenarioRegistry: runner.scenarioRegistry,
128
+ }))),
195
129
  };
196
130
  }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Centers a tag line within the fixed-width ASCII banner header.
3
+ */
4
+ export function padTagLine(tagLine) {
5
+ const headerLength = 51;
6
+ const padding = " ".repeat((headerLength - tagLine.length) / 2);
7
+ return `${padding}${tagLine}`;
8
+ }
9
+ /**
10
+ * Returns a short human-readable message describing which files are being
11
+ * watched or generated, or `undefined` when neither is active.
12
+ */
13
+ export function createWatchMessage(config) {
14
+ switch (true) {
15
+ case config.watch.routes && config.watch.types: {
16
+ return " Watching for changes";
17
+ }
18
+ case config.watch.routes: {
19
+ return " Watching routes for changes";
20
+ }
21
+ case config.watch.types: {
22
+ return " Watching types for changes";
23
+ }
24
+ default: {
25
+ break;
26
+ }
27
+ }
28
+ switch (true) {
29
+ case config.generate.routes && config.generate.types: {
30
+ return "Generating routes and types";
31
+ }
32
+ case config.generate.routes: {
33
+ return "Generating routes";
34
+ }
35
+ case config.generate.types: {
36
+ return "Generating types";
37
+ }
38
+ default: {
39
+ return undefined;
40
+ }
41
+ }
42
+ }
43
+ /**
44
+ * Builds the startup introduction lines that are printed to stdout when
45
+ * Counterfact starts.
46
+ */
47
+ export function createIntroduction(params) {
48
+ const { config, isTelemetryDisabled, source, swaggerUrl, taglines, url, version, } = params;
49
+ const watchMessage = createWatchMessage({
50
+ generate: config.generate,
51
+ watch: config.watch,
52
+ });
53
+ const telemetryWarning = isTelemetryDisabled
54
+ ? []
55
+ : [
56
+ "⚠️ Telemetry will be enabled by default starting May 1, 2026.",
57
+ " Learn more and how to disable: https://counterfact.dev/telemetry-discussion",
58
+ "",
59
+ ];
60
+ const lines = [
61
+ " ____ ____ _ _ _ _ ___ ____ ____ ____ ____ ____ ___",
62
+ String.raw ` |___ [__] |__| |\| | |=== |--< |--- |--| |___ | `,
63
+ " " +
64
+ padTagLine(taglines[Math.floor(Math.random() * taglines.length)] ?? ""),
65
+ "",
66
+ ` Version ${version}`,
67
+ ` API Base URL ${url}`,
68
+ source === "_" ? undefined : ` Swagger UI ${swaggerUrl}`,
69
+ "",
70
+ " Instructions https://counterfact.dev/docs/usage.html",
71
+ " Help/feedback https://github.com/pmcelhaney/counterfact/issues",
72
+ "",
73
+ ...telemetryWarning,
74
+ watchMessage,
75
+ config.startServer ? " Starting server" : undefined,
76
+ config.startRepl
77
+ ? " Starting REPL (type .help for more info)"
78
+ : undefined,
79
+ ];
80
+ return lines.filter((line) => line !== undefined).join("\n");
81
+ }
@@ -0,0 +1,45 @@
1
+ import createDebug from "debug";
2
+ const debug = createDebug("counterfact:cli:check-for-updates");
3
+ /**
4
+ * Returns `true` when `latest` is a strictly higher semver version than
5
+ * `current`.
6
+ */
7
+ export function isOutdated(current, latest) {
8
+ const [cMajor = 0, cMinor = 0, cPatch = 0] = current.split(".").map(Number);
9
+ const [lMajor = 0, lMinor = 0, lPatch = 0] = latest.split(".").map(Number);
10
+ if (lMajor > cMajor)
11
+ return true;
12
+ if (lMajor === cMajor && lMinor > cMinor)
13
+ return true;
14
+ if (lMajor === cMajor && lMinor === cMinor && lPatch > cPatch)
15
+ return true;
16
+ return false;
17
+ }
18
+ /**
19
+ * Checks the npm registry for a newer version of counterfact and prints a
20
+ * warning when one is available. Never throws — update checks are
21
+ * best-effort.
22
+ */
23
+ export async function checkForUpdates(currentVersion) {
24
+ if (process.env["CI"]) {
25
+ debug("skipping update check in CI environment");
26
+ return;
27
+ }
28
+ try {
29
+ const response = await fetch("https://registry.npmjs.org/counterfact/latest");
30
+ if (!response.ok) {
31
+ debug("update check failed with status %d", response.status);
32
+ return;
33
+ }
34
+ const data = (await response.json());
35
+ const latestVersion = data.version;
36
+ if (isOutdated(currentVersion, latestVersion)) {
37
+ process.stdout.write(`\n⚠️ You're running counterfact ${currentVersion}\n`);
38
+ process.stdout.write(` Latest version is ${latestVersion}\n`);
39
+ process.stdout.write(` Run: npx counterfact@latest\n`);
40
+ }
41
+ }
42
+ catch (error) {
43
+ debug("update check error: %o", error);
44
+ }
45
+ }