Skip to content

Commit ee04260

Browse files
authored
Merge pull request #1789 from counterfact/copilot/implement-apply-command-design
feat: implement .apply REPL dot-command (Approach 1: Minimalist Function Injection)
2 parents 5d09384 + fcb0d05 commit ee04260

File tree

12 files changed

+1102
-11
lines changed

12 files changed

+1102
-11
lines changed

.changeset/apply-command.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"counterfact": minor
3+
---
4+
5+
Add `.apply` REPL dot-command (Approach 1: Minimalist Function Injection).
6+
7+
The `.apply` command lets you run scenario scripts from the REPL prompt without leaving the terminal. A scenario is a plain TypeScript (or JavaScript) file that exports named functions. Each function receives an `ApplyContext` object with `{ context, loadContext, routes, route }` and can freely read or mutate state.
8+
9+
**Path resolution:**
10+
11+
| Command | File | Function |
12+
|---|---|---|
13+
| `.apply foo` | `scenarios/index.ts` | `foo` |
14+
| `.apply foo/bar` | `scenarios/foo.ts` | `bar` |
15+
| `.apply foo/bar/baz` | `scenarios/foo/bar.ts` | `baz` |
16+
17+
The `ApplyContext` type is written to `types/apply-context.ts` during code generation.

docs/usage.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -542,6 +542,50 @@ await req.send()
542542

543543
See the [Route Builder guide](./route-builder.md) for full documentation.
544544

545+
### Scenario scripts with `.apply`
546+
547+
For more complex setups you can automate REPL interactions by writing _scenario scripts_ — plain TypeScript files that export named functions. Run them with `.apply`:
548+
549+
```
550+
⬣> .apply soldPets
551+
```
552+
553+
**Path resolution:** the argument to `.apply` is a slash-separated path. The last segment is the function name; everything before it is the file path, resolved relative to `<basePath>/scenarios/` (with `index.ts` as the default file).
554+
555+
| Command | File | Function |
556+
|---|---|---|
557+
| `.apply foo` | `scenarios/index.ts` | `foo` |
558+
| `.apply foo/bar` | `scenarios/foo.ts` | `bar` |
559+
| `.apply foo/bar/baz` | `scenarios/foo/bar.ts` | `baz` |
560+
561+
A scenario function receives a single argument with `{ context, loadContext, routes, route }`:
562+
563+
```ts
564+
// scenarios/index.ts
565+
import type { Scenario } from "../types/scenario-context.js";
566+
567+
export const soldPets: Scenario = ($) => {
568+
// Mutate context directly — same as typing in the REPL
569+
$.context.petService.reset();
570+
$.context.petService.addPet({ id: 1, status: "sold" });
571+
$.context.petService.addPet({ id: 2, status: "available" });
572+
573+
// Store a pre-configured route builder for later use in the REPL
574+
$.routes.findSold = $
575+
.route("/pet/findByStatus")
576+
.method("get")
577+
.query({ status: "sold" });
578+
}
579+
```
580+
581+
After the command runs you can immediately use anything stored in `$.routes`:
582+
583+
```js
584+
> routes.findSold.send()
585+
```
586+
587+
The `Scenario` type and `ApplyContext` interface are generated automatically into `types/scenario-context.ts` when you run Counterfact with type generation enabled.
588+
545589
---
546590

547591
## Proxy 🔀

src/app.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,10 @@ import { loadOpenApiDocument } from "./server/load-openapi-document.js";
1313
import { ModuleLoader } from "./server/module-loader.js";
1414
import { OpenApiWatcher } from "./server/openapi-watcher.js";
1515
import { Registry } from "./server/registry.js";
16+
import { ScenarioRegistry } from "./server/scenario-registry.js";
1617
import { Transpiler } from "./server/transpiler.js";
1718
import { CodeGenerator } from "./typescript-generator/code-generator.js";
19+
import { writeApplyContextType } from "./typescript-generator/generate.js";
1820
import { runtimeCanExecuteErasableTs } from "./util/runtime-can-execute-erasable-ts.js";
1921

2022
export { loadOpenApiDocument } from "./server/load-openapi-document.js";
@@ -114,6 +116,8 @@ export async function counterfact(config: Config) {
114116

115117
const contextRegistry = new ContextRegistry();
116118

119+
const scenarioRegistry = new ScenarioRegistry();
120+
117121
const codeGenerator = new CodeGenerator(
118122
config.openApiPath,
119123
config.basePath,
@@ -142,8 +146,14 @@ export async function counterfact(config: Config) {
142146
compiledPathsDirectory,
143147
registry,
144148
contextRegistry,
149+
nodePath.join(modulesPath, "scenarios").replaceAll("\\", "/"),
150+
scenarioRegistry,
145151
);
146152

153+
contextRegistry.addEventListener("context-changed", () => {
154+
void writeApplyContextType(modulesPath);
155+
});
156+
147157
const middleware = koaMiddleware(dispatcher, config);
148158

149159
const koaApp = createKoaApp(registry, middleware, config, contextRegistry);
@@ -209,6 +219,7 @@ export async function counterfact(config: Config) {
209219
config,
210220
undefined, // use the default print function (stdout)
211221
openApiDocument,
222+
scenarioRegistry,
212223
),
213224
};
214225
}

src/repl/repl.ts

Lines changed: 127 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { Config } from "../server/config.js";
44
import type { ContextRegistry } from "../server/context-registry.js";
55
import type { OpenApiDocument } from "../server/dispatcher.js";
66
import type { Registry } from "../server/registry.js";
7+
import type { ScenarioRegistry } from "../server/scenario-registry.js";
78

89
import { RawHttpClient } from "./raw-http-client.js";
910
import { createRouteFunction } from "./route-builder.js";
@@ -29,11 +30,61 @@ const ROUTE_BUILDER_METHODS = [
2930
"send(",
3031
];
3132

33+
/**
34+
* Creates a tab-completion function for the REPL.
35+
*
36+
* @param registry - The route registry used to complete path arguments for `route()` and `client.*()` calls.
37+
* @param fallback - Optional fallback completer (e.g. the Node.js built-in completer) invoked when no custom completion matches.
38+
* @param scenarioRegistry - When provided, enables tab completion for `.apply` commands by enumerating
39+
* exported function names and file-key prefixes from the loaded scenario modules.
40+
*/
3241
export function createCompleter(
3342
registry: Registry,
3443
fallback?: (line: string, callback: CompleterCallback) => void,
44+
scenarioRegistry?: ScenarioRegistry,
3545
) {
3646
return (line: string, callback: CompleterCallback): void => {
47+
// Check for .apply completion: .apply <partial>
48+
const applyMatch = line.match(/^\.apply\s+(?<partial>\S*)$/u);
49+
50+
if (applyMatch) {
51+
const partial = applyMatch.groups?.["partial"] ?? "";
52+
53+
if (scenarioRegistry !== undefined) {
54+
const slashIdx = partial.lastIndexOf("/");
55+
56+
if (slashIdx === -1) {
57+
// No slash: complete exports from "index" key + top-level file prefixes
58+
const indexFunctions =
59+
scenarioRegistry.getExportedFunctionNames("index");
60+
const fileKeys = scenarioRegistry
61+
.getFileKeys()
62+
.filter((k) => k !== "index");
63+
const topLevelPrefixes = [
64+
...new Set(fileKeys.map((k) => k.split("/")[0] + "/")),
65+
];
66+
const allOptions = [...indexFunctions, ...topLevelPrefixes];
67+
const matches = allOptions.filter((c) => c.startsWith(partial));
68+
69+
callback(null, [matches, partial]);
70+
} else {
71+
// Has slash: complete exports from the named file key
72+
const fileKey = partial.slice(0, slashIdx);
73+
const funcPartial = partial.slice(slashIdx + 1);
74+
const functions = scenarioRegistry.getExportedFunctionNames(fileKey);
75+
const matches = functions
76+
.filter((e) => e.startsWith(funcPartial))
77+
.map((e) => `${fileKey}/${e}`);
78+
79+
callback(null, [matches, partial]);
80+
}
81+
} else {
82+
callback(null, [[], partial]);
83+
}
84+
85+
return;
86+
}
87+
3788
// Check for RouteBuilder method completion: route("..."). or chained calls
3889
const builderMatch = line.match(/route\(.*\)\.(?<partial>[a-zA-Z]*)$/u);
3990

@@ -76,6 +127,7 @@ export function startRepl(
76127
config: Config,
77128
print = printToStdout,
78129
openApiDocument?: OpenApiDocument,
130+
scenarioRegistry?: ScenarioRegistry,
79131
) {
80132
function printProxyStatus() {
81133
if (config.proxyUrl === "") {
@@ -148,7 +200,11 @@ export function startRepl(
148200

149201
// completer is typed as readonly in @types/node but is writable at runtime
150202
// eslint-disable-next-line @typescript-eslint/no-explicit-any
151-
(replServer as any).completer = createCompleter(registry, builtinCompleter);
203+
(replServer as any).completer = createCompleter(
204+
registry,
205+
builtinCompleter,
206+
scenarioRegistry,
207+
);
152208

153209
replServer.defineCommand("counterfact", {
154210
action() {
@@ -214,5 +270,75 @@ export function startRepl(
214270
openApiDocument,
215271
);
216272

273+
replServer.context.routes = {};
274+
275+
replServer.defineCommand("apply", {
276+
async action(text: string) {
277+
const parts = text.trim().split("/").filter(Boolean);
278+
279+
if (parts.length === 0) {
280+
print("usage: .apply <path>");
281+
this.clearBufferedCommand();
282+
this.displayPrompt();
283+
return;
284+
}
285+
286+
if (parts.some((part) => part === ".." || part === ".")) {
287+
print("Error: Path must not contain '.' or '..' segments");
288+
this.clearBufferedCommand();
289+
this.displayPrompt();
290+
return;
291+
}
292+
293+
const functionName = parts[parts.length - 1] ?? "";
294+
const fileKey =
295+
parts.length === 1 ? "index" : parts.slice(0, -1).join("/");
296+
297+
const module = scenarioRegistry?.getModule(fileKey);
298+
299+
if (module === undefined) {
300+
print(`Error: Could not find scenario file "${fileKey}"`);
301+
this.clearBufferedCommand();
302+
this.displayPrompt();
303+
return;
304+
}
305+
306+
const fn = module[functionName];
307+
308+
if (typeof fn !== "function") {
309+
print(
310+
`Error: "${functionName}" is not a function exported from "${fileKey}"`,
311+
);
312+
this.clearBufferedCommand();
313+
this.displayPrompt();
314+
return;
315+
}
316+
317+
try {
318+
const applyContext = {
319+
context: replServer.context["context"] as Record<string, unknown>,
320+
loadContext: replServer.context["loadContext"] as (
321+
path: string,
322+
) => Record<string, unknown>,
323+
route: replServer.context["route"] as (path: string) => unknown,
324+
routes: replServer.context["routes"] as Record<string, unknown>,
325+
};
326+
327+
await (fn as (ctx: typeof applyContext) => Promise<void> | void)(
328+
applyContext,
329+
);
330+
331+
print(`Applied ${text.trim()}`);
332+
} catch (error) {
333+
print(`Error: ${String(error)}`);
334+
}
335+
336+
this.clearBufferedCommand();
337+
this.displayPrompt();
338+
},
339+
340+
help: 'apply a scenario script (".apply <path>" calls the named export from scenarios/)',
341+
});
342+
217343
return replServer;
218344
}

src/server/context-registry.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,14 +40,15 @@ function cloneForCache(value: unknown): unknown {
4040
return clone;
4141
}
4242

43-
export class ContextRegistry {
43+
export class ContextRegistry extends EventTarget {
4444
private readonly entries = new Map<string, Context>();
4545

4646
private readonly cache = new Map<string, Context>();
4747

4848
private readonly seen = new Set<string>();
4949

5050
public constructor() {
51+
super();
5152
this.add("/", {});
5253
}
5354

@@ -67,6 +68,26 @@ export class ContextRegistry {
6768
this.entries.set(path, context);
6869

6970
this.cache.set(path, cloneForCache(context) as Context);
71+
72+
this.dispatchEvent(new Event("context-changed"));
73+
}
74+
75+
/**
76+
* Removes the context entry for the given path and dispatches a
77+
* "context-changed" event so that listeners (e.g. the scenario-context type
78+
* generator) can regenerate type files in response to the removal.
79+
*
80+
* @param path - The route path whose context entry should be deleted
81+
* (e.g. "/pets").
82+
*/
83+
public remove(path: string): void {
84+
this.entries.delete(path);
85+
86+
this.cache.delete(path);
87+
88+
this.seen.delete(path);
89+
90+
this.dispatchEvent(new Event("context-changed"));
7091
}
7192

7293
public find(path: string): Context {

0 commit comments

Comments
 (0)