Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
bc45478
Initial plan
Copilot Apr 8, 2026
56b48ff
Merge branch 'main' into copilot/implement-apply-command-design
pmcelhaney Apr 8, 2026
965dc4e
feat: implement .apply REPL command (Approach 1: Minimalist Function …
Copilot Apr 8, 2026
9c431c1
fix: address code review feedback - rename test params, add path trav…
Copilot Apr 8, 2026
ad25c0c
docs: add .apply command documentation to usage.md
Copilot Apr 8, 2026
3bdc527
feat: update ApplyContext to import Context type; generate default sc…
Copilot Apr 8, 2026
18a7690
fix: use generic example code in default scenarios/index.ts scaffold
Copilot Apr 8, 2026
dacc7d6
feat: scan for _.context.ts files to generate narrowed loadContext ov…
Copilot Apr 9, 2026
8571e86
fix: improve parameter name sanitization and strengthen ordering test…
Copilot Apr 9, 2026
ba514bb
Merge branch 'main' into copilot/implement-apply-command-design
pmcelhaney Apr 9, 2026
b08a259
feat: add tab completion for .apply command in the REPL
Copilot Apr 9, 2026
6ed61e5
fix: document export regex limitation and strengthen tab completion t…
Copilot Apr 9, 2026
0f9bf16
fix: restrict .apply tab completion to exported functions only
Copilot Apr 9, 2026
fb4b293
feat: add ScenarioRegistry; move scenario loading from REPL to Module…
Copilot Apr 9, 2026
46856c2
refactor: fix async test and add JSDoc to createCompleter
Copilot Apr 9, 2026
87415e3
fix: resolve build errors and address code review feedback
Copilot Apr 9, 2026
10937d0
refactor: rename method and extract regex constant per code review
Copilot Apr 9, 2026
726a06f
feat: update scenarios/index.ts scaffold with JSDoc comments and help…
Copilot Apr 10, 2026
4cdebd5
feat: rename apply-context.ts to scenario-context.ts, add Scenario ty…
Copilot Apr 10, 2026
85db3c2
feat: regenerate scenario-context.ts when _.context.ts files change a…
Copilot Apr 10, 2026
5505dcf
refactor: extract isContextFile helper in ModuleLoader
Copilot Apr 10, 2026
e8dd300
refactor: use EventTarget event instead of callback for context-file-…
Copilot Apr 10, 2026
ac90080
Merge branch 'main' into copilot/implement-apply-command-design
pmcelhaney Apr 10, 2026
e1f0e25
refactor: make ContextRegistry an EventTarget, dispatch context-chang…
Copilot Apr 10, 2026
785ca5b
fix: use correct path key when removing context from ContextRegistry …
Copilot Apr 10, 2026
795a039
docs: add JSDoc to ContextRegistry.remove(), simplify context path no…
Copilot Apr 10, 2026
6e7eca0
Merge branch 'main' into copilot/implement-apply-command-design
pmcelhaney Apr 10, 2026
fcb0d05
fix code formatting
pmcelhaney Apr 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/apply-command.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
"counterfact": minor
---

Add `.apply` REPL dot-command (Approach 1: Minimalist Function Injection).

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.

**Path resolution:**

| Command | File | Function |
|---|---|---|
| `.apply foo` | `scenarios/index.ts` | `foo` |
| `.apply foo/bar` | `scenarios/foo.ts` | `bar` |
| `.apply foo/bar/baz` | `scenarios/foo/bar.ts` | `baz` |

The `ApplyContext` type is written to `types/apply-context.ts` during code generation.
44 changes: 44 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -498,6 +498,50 @@ await req.send()

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

### Scenario scripts with `.apply`

For more complex setups you can automate REPL interactions by writing _scenario scripts_ — plain TypeScript files that export named functions. Run them with `.apply`:

```
⬣> .apply soldPets
```

**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).

| Command | File | Function |
|---|---|---|
| `.apply foo` | `scenarios/index.ts` | `foo` |
| `.apply foo/bar` | `scenarios/foo.ts` | `bar` |
| `.apply foo/bar/baz` | `scenarios/foo/bar.ts` | `baz` |

A scenario function receives a single argument with `{ context, loadContext, routes, route }`:

```ts
// scenarios/index.ts
import type { ApplyContext } from "../types/apply-context.js";

export function soldPets(ctx: ApplyContext) {
// Mutate context directly — same as typing in the REPL
ctx.context.petService.reset();
ctx.context.petService.addPet({ id: 1, status: "sold" });
ctx.context.petService.addPet({ id: 2, status: "available" });

// Store a pre-configured route builder for later use in the REPL
ctx.routes.findSold = ctx
.route("/pet/findByStatus")
.method("get")
.query({ status: "sold" });
}
```

After the command runs you can immediately use anything stored in `ctx.routes`:

```js
⬣> routes.findSold.send()
```

The `ApplyContext` type is generated automatically into `types/apply-context.ts` when you run Counterfact with type generation enabled.

---

## Proxy 🔀
Expand Down
104 changes: 104 additions & 0 deletions src/repl/repl.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import fs from "node:fs/promises";
import nodePath from "node:path";
import repl from "node:repl";

import type { Config } from "../server/config.js";
import type { ContextRegistry } from "../server/context-registry.js";
import type { OpenApiDocument } from "../server/dispatcher.js";
import type { Registry } from "../server/registry.js";
import { uncachedImport } from "../server/uncached-import.js";

import { RawHttpClient } from "./raw-http-client.js";
import { createRouteFunction } from "./route-builder.js";
Expand Down Expand Up @@ -214,5 +217,106 @@ export function startRepl(
openApiDocument,
);

replServer.context.routes = {};

replServer.defineCommand("apply", {
async action(text: string) {
const parts = text.trim().split("/").filter(Boolean);

if (parts.length === 0) {
print("usage: .apply <path>");
this.clearBufferedCommand();
this.displayPrompt();
return;
}

if (parts.some((part) => part === ".." || part === ".")) {
print("Error: Path must not contain '.' or '..' segments");
this.clearBufferedCommand();
this.displayPrompt();
return;
}

const functionName = parts[parts.length - 1] ?? "";
const fileParts = parts.slice(0, -1);
const scenariosDir = nodePath.join(config.basePath, "scenarios");
const fileBase =
fileParts.length > 0
? nodePath.join(scenariosDir, ...fileParts)
: nodePath.join(scenariosDir, "index");

// Guard against path traversal: resolved path must stay within scenariosDir
const resolvedBase = nodePath.resolve(fileBase);
const resolvedScenariosDir = nodePath.resolve(scenariosDir);

if (
!resolvedBase.startsWith(resolvedScenariosDir + nodePath.sep) &&
resolvedBase !== resolvedScenariosDir
) {
print("Error: Path must not escape the scenarios directory");
this.clearBufferedCommand();
this.displayPrompt();
return;
}

let filePath: string | undefined;

for (const ext of [".ts", ".js"]) {
const candidate = fileBase + ext;

try {
await fs.access(candidate);
filePath = candidate;
break;
} catch {
// file not found with this extension, try next
}
}

if (filePath === undefined) {
print(`Error: Could not find ${fileBase}.ts or ${fileBase}.js`);
this.clearBufferedCommand();
this.displayPrompt();
return;
}

try {
const mod = await uncachedImport(filePath);
const fn = (mod as Record<string, unknown>)[functionName];

if (typeof fn !== "function") {
print(
`Error: "${functionName}" is not a function exported from ${nodePath.relative(config.basePath, filePath)}`,
);
this.clearBufferedCommand();
this.displayPrompt();
return;
}

const applyContext = {
context: replServer.context["context"] as Record<string, unknown>,
loadContext: replServer.context["loadContext"] as (
path: string,
) => Record<string, unknown>,
route: replServer.context["route"] as (path: string) => unknown,
routes: replServer.context["routes"] as Record<string, unknown>,
};

await (fn as (ctx: typeof applyContext) => Promise<void> | void)(
applyContext,
);

print(`Applied ${text.trim()}`);
} catch (error) {
print(`Error: ${String(error)}`);
}

this.clearBufferedCommand();
this.displayPrompt();
},

help: 'apply a scenario script (".apply <path>" calls the named export from scenarios/)',
});

return replServer;
}
62 changes: 62 additions & 0 deletions src/typescript-generator/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,4 +114,66 @@ export async function generate(
await repository.writeFiles(destination, generateOptions);

debug("finished writing the files");

if (generateOptions.types) {
await writeApplyContextType(destination);
await writeDefaultScenariosIndex(destination);
}
}

const APPLY_CONTEXT_TYPE = `// This file is generated by Counterfact. Do not edit manually.
import type { Context } from "../routes/_.context";

export interface ApplyContext {
/** Root context, same as loadContext("/") */
context: Context;
/** Load a context object for a specific path */
loadContext: (path: string) => Record<string, unknown>;
/** Named route builders stored in the REPL execution context */
routes: Record<string, unknown>;
/** Create a new route builder for a given path */
route: (path: string) => unknown;
}
`;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ApplyContext should import the Context class:

// This file is generated by Counterfact. Do not edit manually.
import type { Context } from "../routes/_.context";

export interface ApplyContext {
  /** Root context, same as loadContext("/") */
  context: Context;
  /** Load a context object for a specific path */
  loadContext: (path: string) => Record<string, unknown>;
  /** Named route builders stored in the REPL execution context */
  routes: Record<string, unknown>;
  /** Create a new route builder for a given path */
  route: (path: string) => unknown;
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated in commit 3bdc527. ApplyContext now imports Context from ../routes/_.context and uses it as the type for the context field.


async function writeApplyContextType(destination: string): Promise<void> {
const typesDir = nodePath.join(destination, "types");
const filePath = nodePath.join(typesDir, "apply-context.ts");

await fs.mkdir(typesDir, { recursive: true });
await fs.writeFile(filePath, APPLY_CONTEXT_TYPE, "utf8");
}

const DEFAULT_SCENARIOS_INDEX = `import type { ApplyContext } from "../types/apply-context.js";

// Scenario scripts are plain TypeScript functions that receive the live REPL
// environment and can read or mutate server state. Run them from the REPL with:
// .apply <functionName>
//
// Example:
// export function myScenario(ctx: ApplyContext) {
// // Read or mutate the root context (same object routes see as $.context)
// // ctx.context.<property> = <value>;
//
// // Load a context for a specific path
// // const petsCtx = ctx.loadContext("/pets");
//
// // Store a pre-configured route builder for later use in the REPL
// // ctx.routes.myRequest = ctx.route("/pets").method("get");
// }
//
// Then in the REPL:
// .apply myScenario
`;

async function writeDefaultScenariosIndex(destination: string): Promise<void> {
const scenariosDir = nodePath.join(destination, "scenarios");
const filePath = nodePath.join(scenariosDir, "index.ts");

if (existsSync(filePath)) {
return;
}

await fs.mkdir(scenariosDir, { recursive: true });
await fs.writeFile(filePath, DEFAULT_SCENARIOS_INDEX, "utf8");
}
Loading
Loading