Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ lerna-debug.log
.env.executors
.forestadmin-schema.json

# forest-bff openapi --output default destination
openapi.json
Comment thread
nbouliol marked this conversation as resolved.

# yarn
yarn-error.log
.vscode/settings.json
Expand Down
23 changes: 21 additions & 2 deletions packages/agent-bff/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ agent from a browser without learning MCP or JSON:API.

It is a bootable Koa 3 server with a `/health` endpoint, a version header, env-driven config
validation, OAuth (Mode 1) + API-key (Mode 2) auth, and a hardened request edge (timezone, CORS,
auth-mode precedence, structured error contract). The data-endpoint proxy and OpenAPI generation
land in later slices.
auth-mode precedence, structured error contract), and it serves and exports its own OpenAPI
document. The data-endpoint proxy lands in a later slice.

## Usage

Expand All @@ -16,6 +16,25 @@ Packaged / production — run the bin:
forest-bff
```

Export the OpenAPI document without booting the server. Needs no configuration, so
Comment thread
Tonours marked this conversation as resolved.
it works in CI to commit the document, diff it, or generate a client:

```bash
forest-bff openapi > openapi.json # stdout, redirected
forest-bff openapi --output # writes ./openapi.json
forest-bff openapi --output docs/api.json # writes that path
```

`--output` takes the next argument as the destination unless it is empty or starts with
`-`; the default `openapi.json` is written only when `--output` is the last argument, and
a leftover token is rejected like any other extra. A missing parent directory is created,
an existing file is overwritten, and a destination that cannot be written exits 1 with the
path on stderr. The path is confirmed on stderr, so stdout stays empty and pipeable.

`forest-bff --help` and `forest-bff --version` print to stdout and exit 0, ignoring
anything that follows. An unknown command, or an argument other than `--output` after
`openapi`, exits 1 with the reason on stderr.

Local development — copy the env template, fill it in, and start with it loaded:

```bash
Expand Down
134 changes: 134 additions & 0 deletions packages/agent-bff/src/cli-dispatch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import type BFFHttpServer from './http/bff-http-server';
import type { Logger } from './ports/logger-port';

import { mkdirSync, writeFileSync } from 'fs';
import path from 'path';

import runCli from './cli-core';
import { extractErrorMessage } from './errors';
import { generateOpenApiDocument, serializeOpenApi } from './openapi/openapi-document';
import version from './version';

export const DEFAULT_OUTPUT_FILE = 'openapi.json';

const OUTPUT_FLAG = '--output';

export const USAGE = `Usage: forest-bff [command]

Commands:
(none) Start the BFF server, configured from the environment.
openapi Write the OpenAPI document to stdout. Needs no configuration.
--output [file] Write to a file instead of stdout, defaulting
to ${DEFAULT_OUTPUT_FILE} in the current directory.

Options:
-h, --help Show this help and exit.
-v, --version Show the package version and exit.`;

export const HINT = "Run 'forest-bff --help' for usage.";

const HELP_FLAGS = new Set(['-h', '--help']);
const VERSION_FLAGS = new Set(['-v', '--version']);

export interface DispatchOutcome {
exitCode: number;
server?: BFFHttpServer;
}

export function renderOpenApi(): string {
return `${serializeOpenApi(generateOpenApiDocument(version))}\n`;
}

function rejectCli(reason: string): DispatchOutcome {
process.stderr.write(`${reason}\n${HINT}\n`);

return { exitCode: 1 };
}

interface OutputOption {
file?: string;
extras: string[];
}

function parseOutputOption(rest: string[]): OutputOption {
const index = rest.indexOf(OUTPUT_FLAG);

if (index === -1) return { extras: rest };

const candidate = rest[index + 1];
const takesValue = candidate !== undefined && candidate !== '' && !candidate.startsWith('-');

return {
file: takesValue ? candidate : DEFAULT_OUTPUT_FILE,
extras: [...rest.slice(0, index), ...rest.slice(index + (takesValue ? 2 : 1))],
};
}

function writeOpenApiFile(file: string): DispatchOutcome {
const asDirectory = file.endsWith('/') || file.endsWith(path.sep);
const destination = path.resolve(
process.cwd(),
asDirectory ? path.join(file, DEFAULT_OUTPUT_FILE) : file,
);

try {
mkdirSync(path.dirname(destination), { recursive: true });
writeFileSync(destination, renderOpenApi());
} catch (error) {
process.stderr.write(`Cannot write ${destination}: ${extractErrorMessage(error)}\n`);

return { exitCode: 1 };
}

process.stderr.write(`Wrote the OpenAPI document to ${destination}\n`);

return { exitCode: 0 };
}

export default async function dispatchCli(
argv: string[],
env: NodeJS.ProcessEnv,
logger?: Logger,
): Promise<DispatchOutcome> {
const [subcommand, ...rest] = argv;

if (subcommand === undefined) {
return { exitCode: 0, server: await runCli(env, logger) };
}

if (HELP_FLAGS.has(subcommand)) {
process.stdout.write(`${USAGE}\n`);

return { exitCode: 0 };
}

if (VERSION_FLAGS.has(subcommand)) {
process.stdout.write(`${version}\n`);

return { exitCode: 0 };
}

if (subcommand !== 'openapi') {
return rejectCli(
subcommand === OUTPUT_FLAG
? `${OUTPUT_FLAG} only applies to the openapi command`
: `Unknown command: ${subcommand}`,
);
}

const { file, extras } = parseOutputOption(rest);

if (extras.length > 0) {
return rejectCli(
`openapi accepts only --output, got: ${extras.map(extra => JSON.stringify(extra)).join(' ')}`,
);
}

if (file !== undefined) {
return writeOpenApiFile(file);
}

process.stdout.write(renderOpenApi());

return { exitCode: 0 };
}
9 changes: 7 additions & 2 deletions packages/agent-bff/src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
#!/usr/bin/env node
/* istanbul ignore file */
import runCli, { reportFatalError } from './cli-core';
import { reportFatalError } from './cli-core';
import dispatchCli from './cli-dispatch';

if (require.main === module) {
runCli(process.env).catch(reportFatalError);
dispatchCli(process.argv.slice(2), process.env)
.then(({ exitCode }) => {
if (exitCode !== 0) process.exitCode = exitCode;
})
.catch(reportFatalError);
}
Loading
Loading