Skip to content

Commit 72bdce1

Browse files
committed
feat(fn-client,fn-cli): programmatic API + thin CLI wrapper
Wave 3 of the portable-functions toolkit. Composes fn-generator into a user-facing layer; closes the loop on the Starship-style split: fn-types → fn-generator → fn-client → fn-cli. @constructive-io/fn-client@0.1.0 - FnClient class wraps FnGenerator and adds: - JSON config loading (fn.config.json or .fnconfig.json) — .ts/.js loading deferred until we add an esbuild/jiti loader. - loadManifest() — reads generated/functions-manifest.json. - defaultProcessDefs() — derives DevProcessDef[] from the manifest. - build({ only? }) — runs `pnpm -r build` with optional filter. - dev({ only?, env?, jobService? }) — spawns Node child processes, returns a DevHandle with pids and a SIGTERM-based stop(). - Job-service is opt-in (jobs-bundle preset is wired by passing `dev({ jobService: ... })`); functions-only consumers omit it. - Smoke tests cover discover/generate/manifest round-trip and defaultProcessDefs derivation. @constructive-io/fn-cli@0.1.0 - bin/fn executable (chmod +x in build) using minimist for argparse. - Subcommands: generate (--only, --packages-only), build, dev, manifest, verify, help. - Each command is ~10 lines: parse → FnClient method → format output. - Verified end-to-end against the brasilia repo: fn generate ⇒ idempotent (0 file changes on rerun) fn manifest ⇒ prints functions-manifest.json fn verify ⇒ "OK: 4 function(s) in sync." The brasilia scripts/generate.ts, scripts/dev.ts, scripts/docker-build.ts remain in place; Wave 4 will collapse them into one-line shims.
1 parent c8e2728 commit 72bdce1

19 files changed

Lines changed: 719 additions & 0 deletions

packages/fn-cli/README.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# @constructive-io/fn-cli
2+
3+
Thin command-line wrapper around `@constructive-io/fn-client`. Exposes the `fn` executable.
4+
5+
## Install
6+
7+
```bash
8+
pnpm add -D @constructive-io/fn-cli
9+
pnpm add @constructive-io/fn-runtime # for handlers
10+
```
11+
12+
## Commands
13+
14+
```bash
15+
fn generate [--only=<name>] [--packages-only]
16+
fn build [--only=<name>]
17+
fn dev [--only=<name>]
18+
fn manifest
19+
fn verify
20+
fn help
21+
```
22+
23+
Common flags: `--root=<dir>`, `--config=<file>`.
24+
25+
This wave (Wave 3) ships `generate`, `build`, `dev`, `manifest`, and `verify`. `init`, `dockerfile`, and `k8s` (standalone manifest emission) land in Waves 4–5.

packages/fn-cli/package.json

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
{
2+
"name": "@constructive-io/fn-cli",
3+
"version": "0.1.0",
4+
"description": "Thin command-line wrapper for the Constructive Functions toolkit. Exposes the `fn` executable.",
5+
"author": "Constructive <developers@constructive.io>",
6+
"license": "SEE LICENSE IN LICENSE",
7+
"main": "dist/index.js",
8+
"types": "dist/index.d.ts",
9+
"bin": {
10+
"fn": "dist/bin/fn.js"
11+
},
12+
"files": [
13+
"dist",
14+
"README.md"
15+
],
16+
"publishConfig": {
17+
"access": "public"
18+
},
19+
"scripts": {
20+
"build": "tsc -p tsconfig.json && chmod +x dist/bin/fn.js",
21+
"clean": "rimraf dist"
22+
},
23+
"dependencies": {
24+
"@constructive-io/fn-client": "workspace:^",
25+
"minimist": "^1.2.8"
26+
},
27+
"devDependencies": {
28+
"@types/minimist": "^1.2.5",
29+
"@types/node": "^22.10.4",
30+
"rimraf": "^5.0.5",
31+
"typescript": "^5.1.6"
32+
}
33+
}

packages/fn-cli/src/bin/fn.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
#!/usr/bin/env node
2+
import { run } from '../cli';
3+
4+
run().then(
5+
(code) => process.exit(code),
6+
(err) => {
7+
process.stderr.write(`fn: ${err?.message ?? err}\n`);
8+
process.exit(1);
9+
}
10+
);

packages/fn-cli/src/cli.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import minimist from 'minimist';
2+
import { commands, type CommandFn } from './commands';
3+
4+
const HELP = `Usage: fn <command> [options]
5+
6+
Commands:
7+
generate Generate workspace packages, k8s YAML, configmaps, skaffold.yaml
8+
Flags: --only=<name> --packages-only
9+
build Run \`pnpm -r build\` (optionally filtered)
10+
Flags: --only=<name>
11+
dev Start functions as local Node processes
12+
Flags: --only=<name>
13+
manifest Print the on-disk functions-manifest.json
14+
verify Sanity-check generated/ vs functions/ (no writes)
15+
help Print this message
16+
17+
Common flags:
18+
--root=<dir> Repo root (default: cwd)
19+
--config=<f> Path to fn.config.json (default: <root>/fn.config.json)
20+
`;
21+
22+
export const run = async (argv: string[] = process.argv.slice(2)): Promise<number> => {
23+
const parsed = minimist(argv, {
24+
string: ['only', 'config', 'root'],
25+
boolean: ['packages-only', 'help', 'version'],
26+
alias: { h: 'help', v: 'version' },
27+
});
28+
29+
const [name] = parsed._;
30+
31+
if (parsed.help || name === 'help' || (!name && argv.length === 0)) {
32+
process.stdout.write(HELP);
33+
return 0;
34+
}
35+
36+
if (parsed.version) {
37+
// eslint-disable-next-line @typescript-eslint/no-var-requires
38+
const pkg = require('../package.json');
39+
process.stdout.write(`${pkg.version}\n`);
40+
return 0;
41+
}
42+
43+
const cmd: CommandFn | undefined = commands[name];
44+
if (!cmd) {
45+
process.stderr.write(`Unknown command: ${name}\n\n${HELP}`);
46+
return 1;
47+
}
48+
return cmd(parsed);
49+
};

packages/fn-cli/src/commands.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { FnClient } from '@constructive-io/fn-client';
2+
import type { ParsedArgs } from 'minimist';
3+
4+
export type CommandFn = (args: ParsedArgs) => number | Promise<number>;
5+
6+
const buildClient = (args: ParsedArgs): FnClient =>
7+
new FnClient({
8+
rootDir: typeof args.root === 'string' ? args.root : undefined,
9+
config: typeof args.config === 'string' ? args.config : undefined,
10+
});
11+
12+
const cmdGenerate: CommandFn = (args) => {
13+
const client = buildClient(args);
14+
const result = client.generate({
15+
only: typeof args.only === 'string' ? args.only : undefined,
16+
packagesOnly: Boolean(args['packages-only']),
17+
});
18+
process.stdout.write(
19+
`Generated ${result.functions.length} function(s); wrote ${result.filesWritten.length} file(s), ${result.symlinksCreated.length} symlink(s).\n`
20+
);
21+
for (const f of result.filesWritten) process.stdout.write(` + ${f}\n`);
22+
for (const s of result.symlinksCreated) process.stdout.write(` ~ ${s}\n`);
23+
return 0;
24+
};
25+
26+
const cmdBuild: CommandFn = async (args) => {
27+
const client = buildClient(args);
28+
await client.build({ only: typeof args.only === 'string' ? args.only : undefined });
29+
return 0;
30+
};
31+
32+
const cmdDev: CommandFn = (args) => {
33+
const client = buildClient(args);
34+
const handle = client.dev({ only: typeof args.only === 'string' ? args.only : undefined });
35+
const stop = (): void => {
36+
handle.stop().finally(() => process.exit(0));
37+
};
38+
process.on('SIGINT', stop);
39+
process.on('SIGTERM', stop);
40+
process.stdout.write(`Started: ${Object.keys(handle.pids).join(', ')}\n`);
41+
// Hold the event loop until a signal arrives.
42+
return new Promise<number>(() => {});
43+
};
44+
45+
const cmdManifest: CommandFn = (args) => {
46+
const client = buildClient(args);
47+
const m = client.loadManifest();
48+
if (!m) {
49+
process.stderr.write('functions-manifest.json not found. Run `fn generate` first.\n');
50+
return 1;
51+
}
52+
process.stdout.write(JSON.stringify(m, null, 2) + '\n');
53+
return 0;
54+
};
55+
56+
const cmdVerify: CommandFn = (args) => {
57+
const client = buildClient(args);
58+
const fns = client.discover();
59+
const manifest = client.loadManifest();
60+
if (!manifest) {
61+
process.stderr.write('functions-manifest.json not found. Run `fn generate` first.\n');
62+
return 1;
63+
}
64+
const expected = new Set(fns.map((f) => f.name));
65+
const actual = new Set(manifest.functions.map((f) => f.name));
66+
const missing = [...expected].filter((n) => !actual.has(n));
67+
const extra = [...actual].filter((n) => !expected.has(n));
68+
if (missing.length === 0 && extra.length === 0) {
69+
process.stdout.write(`OK: ${fns.length} function(s) in sync.\n`);
70+
return 0;
71+
}
72+
if (missing.length) process.stderr.write(`Missing in manifest: ${missing.join(', ')}\n`);
73+
if (extra.length) process.stderr.write(`Stale in manifest: ${extra.join(', ')}\n`);
74+
return 2;
75+
};
76+
77+
export const commands: Record<string, CommandFn> = {
78+
generate: cmdGenerate,
79+
build: cmdBuild,
80+
dev: cmdDev,
81+
manifest: cmdManifest,
82+
verify: cmdVerify,
83+
};

packages/fn-cli/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { run } from './cli';
2+
export { commands } from './commands';

packages/fn-cli/tsconfig.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"extends": "../../tsconfig.json",
3+
"compilerOptions": {
4+
"outDir": "dist",
5+
"rootDir": "src",
6+
"declaration": true
7+
},
8+
"include": ["src/**/*.ts"],
9+
"exclude": ["dist", "node_modules"]
10+
}

packages/fn-client/README.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# @constructive-io/fn-client
2+
3+
Programmatic client for the Constructive Functions toolkit. Composes `@constructive-io/fn-generator` with config loading, manifest readers, and child-process orchestration so a host repo (or a thin CLI) can drive the whole pipeline from one object.
4+
5+
## Usage
6+
7+
```ts
8+
import { FnClient } from '@constructive-io/fn-client';
9+
10+
const client = new FnClient({
11+
rootDir: process.cwd(), // default
12+
config: { functionsDir: 'functions', outputDir: 'generated' },
13+
});
14+
15+
// 1. Generate workspace packages, k8s manifests, configmaps, skaffold.yaml.
16+
client.generate();
17+
18+
// 2. Build the generated packages (runs `pnpm -r build`).
19+
await client.build();
20+
21+
// 3. Start functions as local Node processes; stop them when done.
22+
const dev = client.dev({
23+
env: { GRAPHQL_URL: 'http://localhost:3002/graphql' },
24+
});
25+
26+
await new Promise((r) => process.once('SIGINT', r));
27+
await dev.stop();
28+
```
29+
30+
## Surface
31+
32+
- `discover(only?)` — list discovered functions with resolved port/type.
33+
- `generate(opts?)` — run the full generator pipeline (delegates to `FnGenerator`).
34+
- `loadManifest()` — read the on-disk `functions-manifest.json`.
35+
- `defaultProcessDefs()` — derive `DevProcessDef[]` from the manifest (one per function, pointing at `<outputDir>/<dir>/dist/index.js`).
36+
- `build(opts?)``pnpm -r build`, optionally filtered.
37+
- `dev(opts?)` — spawn process defs as `node` children, return a `DevHandle` with `pids` and `stop()`.
38+
39+
The job-service is optional: pass `dev({ jobService: { name, script, port, env } })` to start it alongside the functions; omit to run functions only.
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import * as fs from 'fs';
2+
import * as os from 'os';
3+
import * as path from 'path';
4+
import { FnClient } from '../src';
5+
6+
const ROOT = path.resolve(__dirname, '..', '..', '..');
7+
8+
describe('FnClient', () => {
9+
let tmpRoot: string;
10+
11+
beforeEach(() => {
12+
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'fn-client-test-'));
13+
fs.symlinkSync(path.join(ROOT, 'functions'), path.join(tmpRoot, 'functions'));
14+
fs.symlinkSync(path.join(ROOT, 'templates'), path.join(tmpRoot, 'templates'));
15+
});
16+
17+
afterEach(() => {
18+
fs.rmSync(tmpRoot, { recursive: true, force: true });
19+
});
20+
21+
it('discovers functions in fs.readdirSync order', () => {
22+
const client = new FnClient({ rootDir: tmpRoot });
23+
const fns = client.discover();
24+
const names = fns.map((f) => f.name);
25+
expect(names).toContain('simple-email');
26+
expect(names).toContain('send-email-link');
27+
expect(fns.every((f) => f.port > 0 && f.port !== 8080)).toBe(true);
28+
});
29+
30+
it('generate() writes a manifest that loadManifest() can read', () => {
31+
const client = new FnClient({ rootDir: tmpRoot });
32+
const result = client.generate();
33+
expect(result.functions.length).toBeGreaterThan(0);
34+
const m = client.loadManifest();
35+
expect(m).not.toBeNull();
36+
expect(m!.functions.map((f) => f.name).sort()).toEqual(
37+
result.functions.map((f) => f.name).sort()
38+
);
39+
});
40+
41+
it('defaultProcessDefs() builds one entry per function pointing at dist/index.js', () => {
42+
const client = new FnClient({ rootDir: tmpRoot });
43+
client.generate();
44+
const defs = client.defaultProcessDefs();
45+
expect(defs.length).toBe(client.discover().length);
46+
for (const def of defs) {
47+
expect(def.script).toMatch(/dist\/index\.js$/);
48+
expect(typeof def.port).toBe('number');
49+
}
50+
});
51+
});

packages/fn-client/jest.config.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
module.exports = {
2+
preset: 'ts-jest',
3+
testEnvironment: 'node',
4+
testMatch: ['**/__tests__/**/*.test.ts'],
5+
collectCoverageFrom: ['src/**/*.ts'],
6+
};

0 commit comments

Comments
 (0)