counterfact 2.7.0 → 2.9.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 (57) hide show
  1. package/README.md +5 -160
  2. package/bin/README.md +39 -14
  3. package/bin/counterfact.js +18 -539
  4. package/bin/ts-loader.mjs +1 -0
  5. package/dist/api-runner.js +202 -0
  6. package/dist/app.js +102 -114
  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 +3 -3
  13. package/dist/msw.js +78 -0
  14. package/dist/repl/raw-http-client.js +22 -1
  15. package/dist/repl/repl.js +250 -63
  16. package/dist/repl/route-builder.js +68 -0
  17. package/dist/server/constants.js +8 -0
  18. package/dist/server/context-registry.js +54 -1
  19. package/dist/server/determine-module-kind.js +14 -0
  20. package/dist/server/dispatcher.js +46 -0
  21. package/dist/server/file-discovery.js +21 -9
  22. package/dist/server/is-proxy-enabled-for-path.js +12 -0
  23. package/dist/server/json-to-xml.js +10 -0
  24. package/dist/server/load-openapi-document.js +4 -11
  25. package/dist/server/module-dependency-graph.js +25 -0
  26. package/dist/server/module-loader.js +52 -21
  27. package/dist/server/module-tree.js +36 -0
  28. package/dist/server/openapi-document.js +69 -0
  29. package/dist/server/registry.js +89 -0
  30. package/dist/server/response-builder.js +15 -0
  31. package/dist/server/scenario-registry.js +26 -0
  32. package/dist/server/tools.js +27 -0
  33. package/dist/server/transpiler.js +24 -9
  34. package/dist/server/{admin-api-middleware.js → web-server/admin-api-middleware.js} +19 -9
  35. package/dist/server/web-server/create-koa-app.js +68 -0
  36. package/dist/server/web-server/openapi-middleware.js +34 -0
  37. package/dist/server/{koa-middleware.js → web-server/routes-middleware.js} +26 -6
  38. package/dist/typescript-generator/code-generator.js +118 -4
  39. package/dist/typescript-generator/coder.js +76 -0
  40. package/dist/typescript-generator/operation-coder.js +12 -4
  41. package/dist/typescript-generator/operation-type-coder.js +39 -4
  42. package/dist/typescript-generator/parameters-type-coder.js +2 -4
  43. package/dist/typescript-generator/prune.js +3 -1
  44. package/dist/typescript-generator/repository.js +77 -20
  45. package/dist/typescript-generator/requirement.js +69 -0
  46. package/dist/typescript-generator/{generate.js → scenario-file-generator.js} +99 -81
  47. package/dist/typescript-generator/script.js +70 -7
  48. package/dist/typescript-generator/specification.js +27 -0
  49. package/dist/util/ensure-directory-exists.js +8 -0
  50. package/dist/util/forward-slash-path.js +63 -0
  51. package/dist/util/load-config-file.js +2 -2
  52. package/dist/util/read-file.js +27 -2
  53. package/dist/util/runtime-can-execute-erasable-ts.js +12 -0
  54. package/dist/util/windows-escape.js +18 -0
  55. package/package.json +5 -4
  56. package/dist/server/create-koa-app.js +0 -42
  57. package/dist/server/openapi-middleware.js +0 -19
@@ -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,113 +1,100 @@
1
- import fs, { rm } from "node:fs/promises";
2
- import nodePath from "node:path";
3
1
  import { createHttpTerminator } from "http-terminator";
2
+ import { ApiRunner } from "./api-runner.js";
4
3
  import { startRepl as startReplServer } from "./repl/repl.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 { koaMiddleware } from "./server/koa-middleware.js";
9
- import { loadOpenApiDocument } from "./server/load-openapi-document.js";
10
- import { ModuleLoader } from "./server/module-loader.js";
11
- import { OpenApiWatcher } from "./server/openapi-watcher.js";
12
- import { Registry } from "./server/registry.js";
13
- import { ScenarioRegistry } from "./server/scenario-registry.js";
14
- import { Transpiler } from "./server/transpiler.js";
15
- import { CodeGenerator } from "./typescript-generator/code-generator.js";
16
- import { writeApplyContextType } from "./typescript-generator/generate.js";
17
- import { runtimeCanExecuteErasableTs } from "./util/runtime-can-execute-erasable-ts.js";
4
+ import { createRouteFunction } from "./repl/route-builder.js";
5
+ import { createKoaApp } from "./server/web-server/create-koa-app.js";
18
6
  export { loadOpenApiDocument } from "./server/load-openapi-document.js";
19
- const allowedMethods = [
20
- "all",
21
- "head",
22
- "get",
23
- "post",
24
- "put",
25
- "delete",
26
- "patch",
27
- "options",
28
- ];
29
- const mswHandlers = {};
30
- export async function handleMswRequest(request) {
31
- const { method, rawPath } = request;
32
- const handler = mswHandlers[`${method}:${rawPath}`];
33
- if (handler) {
34
- return handler(request);
7
+ export { createMswHandlers, handleMswRequest, } from "./msw.js";
8
+ export async function runStartupScenario(scenarioRegistry, contextRegistry, config, openApiDocument) {
9
+ const indexModule = scenarioRegistry.getModule("index");
10
+ if (!indexModule || typeof indexModule["startup"] !== "function") {
11
+ return;
35
12
  }
36
- console.warn(`No handler found for ${method} ${rawPath}`);
37
- return { error: `No handler found for ${method} ${rawPath}`, status: 404 };
13
+ const scenario$ = {
14
+ context: contextRegistry.find("/"),
15
+ loadContext: (path) => contextRegistry.find(path),
16
+ route: createRouteFunction(config.port, "localhost", openApiDocument),
17
+ routes: {},
18
+ };
19
+ await indexModule["startup"](scenario$);
38
20
  }
39
- export async function createMswHandlers(config, ModuleLoaderClass = ModuleLoader) {
40
- // 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.
41
- // If we "pre-read" the file here it works. This is a workaround to avoid the issue.
42
- await fs.readFile(config.openApiPath);
43
- const openApiDocument = await loadOpenApiDocument(config.openApiPath);
44
- const modulesPath = config.basePath;
45
- const compiledPathsDirectory = nodePath
46
- .join(modulesPath, ".cache")
47
- .replaceAll("\\", "/");
48
- const registry = new Registry();
49
- const contextRegistry = new ContextRegistry();
50
- const dispatcher = new Dispatcher(registry, contextRegistry, openApiDocument, config);
51
- const moduleLoader = new ModuleLoaderClass(compiledPathsDirectory, registry, contextRegistry);
52
- await moduleLoader.load();
53
- const routes = registry.routes;
54
- const handlers = routes.flatMap((route) => {
55
- const { methods, path } = route;
56
- return Object.keys(methods)
57
- .filter((method) => allowedMethods.includes(method.toLowerCase()))
58
- .map((method) => {
59
- const lowerMethod = method.toLowerCase();
60
- const apiPath = `${openApiDocument.basePath ?? ""}${path.replaceAll("{", ":").replaceAll("}", "")}`;
61
- const handler = async (request) => {
62
- return await dispatcher.request(request);
63
- };
64
- mswHandlers[`${method}:${apiPath}`] = handler;
65
- return { method: lowerMethod, path: apiPath };
66
- });
67
- });
68
- return handlers;
21
+ /**
22
+ * Normalises the spec configuration to an array.
23
+ *
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.
28
+ */
29
+ function normalizeSpecs(config, specs) {
30
+ if (specs !== undefined) {
31
+ return specs;
32
+ }
33
+ return [{ source: config.openApiPath, prefix: config.prefix, group: "" }];
69
34
  }
70
- export async function counterfact(config) {
71
- const modulesPath = config.basePath;
72
- const nativeTs = await runtimeCanExecuteErasableTs();
73
- const compiledPathsDirectory = nodePath
74
- .join(modulesPath, nativeTs ? "routes" : ".cache")
75
- .replaceAll("\\", "/");
76
- if (!nativeTs) {
77
- await rm(compiledPathsDirectory, { force: true, recursive: true });
35
+ function validateSpecGroups(specs) {
36
+ if (specs.length <= 1) {
37
+ return;
78
38
  }
79
- const registry = new Registry();
80
- const contextRegistry = new ContextRegistry();
81
- const scenarioRegistry = new ScenarioRegistry();
82
- const codeGenerator = new CodeGenerator(config.openApiPath, config.basePath, config.generate);
83
- const openApiDocument = config.openApiPath === "_"
84
- ? undefined
85
- : await loadOpenApiDocument(config.openApiPath);
86
- const dispatcher = new Dispatcher(registry, contextRegistry, openApiDocument, config);
87
- const transpiler = new Transpiler(nodePath.join(modulesPath, "routes").replaceAll("\\", "/"), compiledPathsDirectory, "commonjs");
88
- const moduleLoader = new ModuleLoader(compiledPathsDirectory, registry, contextRegistry, nodePath.join(modulesPath, "scenarios").replaceAll("\\", "/"), scenarioRegistry);
89
- contextRegistry.addEventListener("context-changed", () => {
90
- void writeApplyContextType(modulesPath);
91
- });
92
- const middleware = koaMiddleware(dispatcher, config);
93
- const koaApp = createKoaApp(registry, middleware, config, contextRegistry);
94
- const openApiWatcher = new OpenApiWatcher(config.openApiPath, dispatcher);
95
- async function start(options) {
96
- const { generate, startServer, watch, buildCache } = options;
97
- if (config.openApiPath !== "_" && (generate.routes || generate.types)) {
98
- await codeGenerator.generate();
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);
99
53
  }
100
- if (config.openApiPath !== "_" && (watch.routes || watch.types)) {
101
- await codeGenerator.watch();
54
+ if (duplicateGroupNames.size === 0) {
55
+ return;
102
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(", ")}).`);
60
+ }
61
+ /**
62
+ * Creates and configures a full Counterfact server instance.
63
+ *
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.
70
+ *
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`.
74
+ * @returns An object containing the configured sub-systems and two entry-point
75
+ * functions:
76
+ * - `start(options)` — generates/watches code and optionally starts the HTTP
77
+ * server; returns a `stop()` handle.
78
+ * - `startRepl()` — launches the interactive Node.js REPL connected to the
79
+ * live server state.
80
+ */
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)));
85
+ const koaApp = createKoaApp({
86
+ runners,
87
+ config,
88
+ });
89
+ // The REPL is configured using the first runner.
90
+ const primaryRunner = runners[0];
91
+ async function start(options) {
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)));
103
95
  let httpTerminator;
104
- if (startServer) {
105
- await openApiWatcher.watch();
106
- if (!nativeTs) {
107
- await transpiler.watch();
108
- }
109
- await moduleLoader.load();
110
- await moduleLoader.watch();
96
+ if (options.startServer) {
97
+ await runStartupScenario(primaryRunner.scenarioRegistry, primaryRunner.contextRegistry, { port: config.port }, primaryRunner.openApiDocument);
111
98
  const server = koaApp.listen({
112
99
  port: config.port,
113
100
  });
@@ -115,28 +102,29 @@ export async function counterfact(config) {
115
102
  server,
116
103
  });
117
104
  }
118
- else if (buildCache) {
119
- // If we are not starting the server, we still want to transpile and load modules
120
- await transpiler.watch();
121
- await transpiler.stopWatching();
122
- }
123
105
  return {
124
106
  async stop() {
125
- await codeGenerator.stopWatching();
126
- await transpiler.stopWatching();
127
- await moduleLoader.stopWatching();
128
- await openApiWatcher.stopWatching();
107
+ await Promise.all(runners.map((runner) => runner.stopWatching()));
129
108
  await httpTerminator?.terminate();
130
109
  },
131
110
  };
132
111
  }
133
112
  return {
134
- contextRegistry,
113
+ contextRegistry: primaryRunner.contextRegistry,
135
114
  koaApp,
136
- koaMiddleware: middleware,
137
- registry,
115
+ registry: primaryRunner.registry,
138
116
  start,
139
- startRepl: () => startReplServer(contextRegistry, registry, config, undefined, // use the default print function (stdout)
140
- 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
+ }))),
141
129
  };
142
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
+ }