diff --git a/README.md b/README.md index cd2d92c..af4489f 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,36 @@ import { compose } from "./my-function.js"; serve(compose); ``` +### Custom Flags with the Shareable CLI + +If your function needs its own command-line flags or environment variables, use the +`CLI` class instead of calling `serve()` directly. It provides the same standard +flags that `serve()` uses, plus a way to register your own: + +```typescript +import { CLI, serve } from "@crossplane-org/function-sdk-typescript"; +import { compose } from "./my-function.js"; + +const cli = new CLI({ + flags: { + "cache-size": { type: "string", default: "100", env: "CACHE_SIZE", description: "Number of entries to cache." }, + }, +}); + +const parsed = cli.parse(); +if (parsed.help) { + process.stdout.write(cli.helpText() + "\n"); + process.exit(0); +} + +serve(compose, { argv: [], serverOptions: cli.standardOptions(), logger: cli.logger() }); +``` + +See [USAGE.md](USAGE.md#custom-flags-with-the-shareable-cli) for the full list of +standard flags, environment variables, flag spec options, and a more complete example. + +### Using the Full FunctionHandler Interface + If your function needs the full interface, implement `FunctionHandler` — `serve()` accepts either: @@ -667,11 +697,20 @@ import { ComposeFunction, ComposeResponse, ServeOptions, + + // Shareable CLI + CLI, + CLIOptions, + FlagSpec, } from "@crossplane-org/function-sdk-typescript"; ``` ### Core Functions +#### CLI + +- **`CLI`** - Shareable, extensible CLI with standard flags, env var support, and custom flag registration. See [USAGE.md](USAGE.md#shareable-cli) for the full method list. + #### Server Functions - **`serve(fn, opts?)`** - Run a `ComposeFunction` or `FunctionHandler` as a gRPC server diff --git a/USAGE.md b/USAGE.md index 6dffcf0..7fd5df2 100644 --- a/USAGE.md +++ b/USAGE.md @@ -153,18 +153,14 @@ and `SIGTERM`. It accepts either a `ComposeFunction` or a `FunctionHandler`. Every function served this way accepts the same flags: -``` -Usage: main.js [flags] - -A Crossplane composition function. - -Flags: - --address Address to listen for gRPC connections. Default 0.0.0.0:9443. - -d, --debug Emit debug logs. - --insecure Run without mTLS credentials. - --tls-server-certs-dir Directory holding tls.key, tls.crt and ca.crt. Default /tls/server. - -h, --help Show this help. -``` +| Flag | Env | Default | Description | +|------|-----|---------|-------------| +| `--address` | `ADDRESS` | `0.0.0.0:9443` | gRPC listen address | +| `--debug` / `-d` | `DEBUG` | `false` | Emit debug logs | +| `--insecure` | `INSECURE` | `false` | Run without mTLS | +| `--tls-server-certs-dir` | `TLS_SERVER_CERTS_DIR` | `/tls/server` | mTLS certificate directory | +| `--max-recv-message-size` | `MAX_RECV_MESSAGE_SIZE` | `4` | Max gRPC message size in MB | +| `--help` / `-h` | — | `false` | Show help text | `serve()` takes an options object for the cases where the defaults do not fit: @@ -192,6 +188,69 @@ node dist/main.js --tls-server-certs-dir /path/to/certs ## Advanced Usage +### Custom Flags with the Shareable CLI + +If your function needs its own command-line flags or environment variables, use the +`CLI` class. It provides the same standard flags that `serve()` uses, plus a way to +register your own: + +```typescript +#!/usr/bin/env node + +import { CLI, serve } from "@crossplane-org/function-sdk-typescript"; +import { compose } from "./my-function.js"; + +const cli = new CLI({ + flags: { + "cache-size": { + type: "string", + default: "100", + env: "CACHE_SIZE", + description: "Number of entries to cache.", + }, + "verbose": { + type: "boolean", + default: false, + short: "v", + env: "VERBOSE", + description: "Enable verbose output.", + }, + }, +}); + +try { + const parsed = cli.parse(); + if (parsed.help) { + process.stdout.write(cli.helpText() + "\n"); + process.exit(0); + } + + // Pass the CLI's standard options and logger to serve, skipping its + // internal argument parsing by supplying an empty argv. + serve(compose, { + argv: [], + serverOptions: cli.standardOptions(), + logger: cli.logger(), + }); + + const cacheSize = Number(parsed["cache-size"]); +} catch (error) { + process.stderr.write(`${cli.name}: ${error instanceof Error ? error.message : error}\n`); + process.exit(2); +} +``` + +Each custom flag supports: + +- **`type`** — `"string"` or `"boolean"`. +- **`default`** — Value used when neither the CLI argument nor the env var is set. +- **`short`** — Single-character alias (e.g. `-v`). +- **`env`** — Environment variable name. Resolution order: CLI argument > env var > default. +- **`description`** — Shown in `--help` output. + +Custom flag names must not collide with the standard flags; the constructor throws if +they do. + ### Building the Server Yourself `serve()` is the recommended entry point, but the pieces it uses are exported, so a @@ -363,6 +422,15 @@ normal(rsp, "Function completed successfully"); - `normal(rsp, message)` - Add normal info result - `update(source, target)` - Deep merge resources using ts-deepmerge +### Shareable CLI + +- `new CLI(opts?)` - Create a CLI with optional custom flags +- `cli.parse(argv?)` - Parse arguments (CLI arg > env var > default) +- `cli.standardOptions()` - Get `ServerOptions` from the standard flags +- `cli.logger()` - Get a pino Logger configured from `--debug` +- `cli.helpText()` - Generate help text including custom flags +- `cli.name` - The program name used in help text + ### Runtime - `serve(fn, opts?)` - Run a `ComposeFunction` or `FunctionHandler` as a gRPC server: parses flags, builds a logger, starts the server, handles shutdown diff --git a/package-lock.json b/package-lock.json index acfa602..4fac5a7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -419,6 +419,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -451,7 +452,6 @@ "integrity": "sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/Boshen" } @@ -532,7 +532,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -550,7 +549,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -568,7 +566,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -586,7 +583,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -604,7 +600,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -622,7 +617,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -640,7 +634,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -658,7 +651,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -676,7 +668,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -694,7 +685,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -712,7 +702,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -730,7 +719,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -748,7 +736,6 @@ "os": [ "openharmony" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -766,7 +753,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -784,7 +770,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -794,8 +779,7 @@ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@tsconfig/node10": { "version": "1.0.12", @@ -876,6 +860,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.5.tgz", "integrity": "sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } @@ -925,6 +910,7 @@ "integrity": "sha512-fHq2VC1kpyYfvEcbiMjOpySY4WS7voEp89yAThrHRX5sm9j2lzYppCb2umFMEed4fWcyeLjHxrz0mpjNBaBxMQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.68.0", "@typescript-eslint/types": "8.68.0", @@ -1507,6 +1493,7 @@ "integrity": "sha512-toMg6PZGCIa/lQNCDoASrfb1ly4hsUKXFtFYC9kD4t78o5Y6LyNJU7AENt8eHPr3quYdxaxK7hj2mnbFfUk9NA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/istanbul-lib-coverage": "^1.0.0", @@ -1607,6 +1594,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1642,6 +1630,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -1986,6 +1975,7 @@ "integrity": "sha512-9VaAkDURekixUQJy0oJYl2DcN6oKMfxay7XzaGYAWQwsb6qfKf+x76R2k1L8kb1boc+FyCAaTA9GmiKaaiaF+A==", "dev": true, "license": "MIT", + "peer": true, "workspaces": [ "packages/*" ], @@ -2276,7 +2266,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } @@ -2451,7 +2440,6 @@ "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", "dev": true, "license": "MPL-2.0", - "peer": true, "dependencies": { "detect-libc": "^2.0.3" }, @@ -2489,7 +2477,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -2511,7 +2498,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -2533,7 +2519,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -2555,7 +2540,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -2577,7 +2561,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -2599,7 +2582,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -2621,7 +2603,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -2643,7 +2624,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -2665,7 +2645,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -2687,7 +2666,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -2709,7 +2687,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -2724,7 +2701,6 @@ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=8" } @@ -2817,7 +2793,6 @@ } ], "license": "MIT", - "peer": true, "bin": { "nanoid": "bin/nanoid.cjs" }, @@ -2952,8 +2927,7 @@ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "dev": true, - "license": "ISC", - "peer": true + "license": "ISC" }, "node_modules/picomatch": { "version": "4.0.7", @@ -3025,7 +2999,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.17", "picocolors": "^1.1.1", @@ -3186,7 +3159,6 @@ "integrity": "sha512-vMM4q3aixf46GiF1Kok8jDPFsEpXgFWGjUHXNkNHNm+Y2adXAG2dbX91jkti3i0ZRsOlcmbuzAz1poObSHCmUA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@oxc-project/types": "=0.147.0", "@rolldown/pluginutils": "^1.0.0" @@ -3534,6 +3506,7 @@ "integrity": "sha512-mbCddXd+jm7hfx7w2YU64/Av4/NqqeG3GoRZgxPcgoTxYjhrcfJRw9ULch71SS4G+Q3bOXFhRvPqjguN0Hyp5w==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@typescript/old": "npm:typescript@^6" }, @@ -3672,6 +3645,7 @@ "integrity": "sha512-gpsMNoRhMjMktVxPtstOH4/PJuPyovVaMDr4oDilXaGH1EcqM2OE96SoHT2VIQ6fTGtTjqmHDrEu2X9RQiXf8Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/mocker": "5.0.0", diff --git a/src/cli/cli.test.ts b/src/cli/cli.test.ts new file mode 100644 index 0000000..75f19ce --- /dev/null +++ b/src/cli/cli.test.ts @@ -0,0 +1,272 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { CLI } from './cli.js'; + +describe('CLI', () => { + const savedEnv: Record = {}; + const envVars = ['ADDRESS', 'DEBUG', 'INSECURE', 'TLS_SERVER_CERTS_DIR', 'MAX_RECV_MESSAGE_SIZE', 'MY_FLAG']; + + beforeEach(() => { + for (const key of envVars) { + savedEnv[key] = process.env[key]; + delete process.env[key]; + } + }); + + afterEach(() => { + for (const key of envVars) { + if (savedEnv[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = savedEnv[key]; + } + } + }); + + describe('parse', () => { + it('should default every standard flag', () => { + const cli = new CLI({ name: 'test' }); + const values = cli.parse([]); + + expect(values).toEqual({ + address: '0.0.0.0:9443', + debug: false, + insecure: false, + 'tls-server-certs-dir': '/tls/server', + 'max-recv-message-size': '4', + help: false, + }); + }); + + it('should parse standard flags', () => { + const cli = new CLI({ name: 'test' }); + const values = cli.parse([ + '--address', + 'localhost:1234', + '--debug', + '--insecure', + '--tls-server-certs-dir', + '/certs', + ]); + + expect(values.address).toBe('localhost:1234'); + expect(values.debug).toBe(true); + expect(values.insecure).toBe(true); + expect(values['tls-server-certs-dir']).toBe('/certs'); + }); + + it('should accept short flags', () => { + const cli = new CLI({ name: 'test' }); + expect(cli.parse(['-d']).debug).toBe(true); + expect(new CLI({ name: 'test' }).parse(['-h']).help).toBe(true); + }); + + it('should reject unrecognized flags', () => { + const cli = new CLI({ name: 'test' }); + expect(() => cli.parse(['--nope'])).toThrow(/Unknown option '--nope'/); + }); + + it('should parse custom flags alongside standard ones', () => { + const cli = new CLI({ + name: 'test', + flags: { + 'my-flag': { + type: 'string', + default: 'foo', + env: 'MY_FLAG', + description: 'My custom flag.', + }, + verbose: { + type: 'boolean', + default: false, + description: 'Verbose output.', + }, + }, + }); + + const values = cli.parse(['--my-flag', 'bar', '--verbose', '--debug']); + expect(values['my-flag']).toBe('bar'); + expect(values.verbose).toBe(true); + expect(values.debug).toBe(true); + }); + + it('should reject custom flags that conflict with standard flags', () => { + expect( + () => + new CLI({ + flags: { + debug: { type: 'boolean', description: 'Conflict.' }, + }, + }) + ).toThrow(/flag "debug" conflicts with a standard flag/); + }); + }); + + describe('environment variables', () => { + it('should read string flags from env when not given on CLI', () => { + process.env.ADDRESS = 'env-address:9999'; + const cli = new CLI({ name: 'test' }); + const values = cli.parse([]); + + expect(values.address).toBe('env-address:9999'); + }); + + it('should read boolean flags from env', () => { + process.env.DEBUG = 'true'; + const cli = new CLI({ name: 'test' }); + + expect(cli.parse([]).debug).toBe(true); + }); + + it('should accept "1" as truthy for boolean env vars', () => { + process.env.INSECURE = '1'; + const cli = new CLI({ name: 'test' }); + + expect(cli.parse([]).insecure).toBe(true); + }); + + it('should treat other env values as falsy for booleans', () => { + process.env.DEBUG = 'no'; + const cli = new CLI({ name: 'test' }); + + expect(cli.parse([]).debug).toBe(false); + }); + + it('should prefer CLI argument over env var', () => { + process.env.ADDRESS = 'env-address:9999'; + const cli = new CLI({ name: 'test' }); + const values = cli.parse(['--address', 'cli-address:8888']); + + expect(values.address).toBe('cli-address:8888'); + }); + + it('should read custom flag env vars', () => { + process.env.MY_FLAG = 'from-env'; + const cli = new CLI({ + name: 'test', + flags: { + 'my-flag': { + type: 'string', + default: 'default-val', + env: 'MY_FLAG', + description: 'Test.', + }, + }, + }); + + expect(cli.parse([])['my-flag']).toBe('from-env'); + }); + + it('should prefer env over default but CLI over env', () => { + process.env.MY_FLAG = 'from-env'; + const cli = new CLI({ + name: 'test', + flags: { + 'my-flag': { + type: 'string', + default: 'default-val', + env: 'MY_FLAG', + description: 'Test.', + }, + }, + }); + + expect(cli.parse(['--my-flag', 'from-cli'])['my-flag']).toBe('from-cli'); + }); + }); + + describe('standardOptions', () => { + it('should return ServerOptions from parsed standard flags', () => { + const cli = new CLI({ name: 'test' }); + cli.parse(['--address', 'localhost:5555', '--debug', '--insecure']); + + expect(cli.standardOptions()).toEqual({ + address: 'localhost:5555', + debug: true, + insecure: true, + tlsServerCertsDir: '/tls/server', + maxRecvMessageSize: 4 * 1024 * 1024, + }); + }); + + it('should convert max-recv-message-size from MB to bytes', () => { + const cli = new CLI({ name: 'test' }); + cli.parse(['--max-recv-message-size', '8']); + + expect(cli.standardOptions().maxRecvMessageSize).toBe(8 * 1024 * 1024); + }); + + it('should reflect env vars in standard options', () => { + process.env.ADDRESS = 'env:1111'; + process.env.DEBUG = 'true'; + const cli = new CLI({ name: 'test' }); + cli.parse([]); + + const opts = cli.standardOptions(); + expect(opts.address).toBe('env:1111'); + expect(opts.debug).toBe(true); + }); + }); + + describe('logger', () => { + it('should return info-level logger by default', () => { + const cli = new CLI({ name: 'test' }); + cli.parse([]); + + expect(cli.logger().level).toBe('info'); + }); + + it('should return debug-level logger when --debug is set', () => { + const cli = new CLI({ name: 'test' }); + cli.parse(['--debug']); + + expect(cli.logger().level).toBe('debug'); + }); + }); + + describe('helpText', () => { + it('should include program name and all standard flags', () => { + const cli = new CLI({ name: 'my-fn' }); + const help = cli.helpText(); + + expect(help).toContain('Usage: my-fn'); + for (const flag of ['--address', '--debug', '--insecure', '--tls-server-certs-dir', '--max-recv-message-size', '--help']) { + expect(help).toContain(flag); + } + }); + + it('should include custom flags', () => { + const cli = new CLI({ + name: 'my-fn', + flags: { + 'my-flag': { + type: 'string', + default: 'foo', + env: 'MY_FLAG', + description: 'My custom flag.', + }, + }, + }); + const help = cli.helpText(); + + expect(help).toContain('--my-flag'); + expect(help).toContain('My custom flag.'); + expect(help).toContain('[env: MY_FLAG]'); + }); + + it('should show env var hints for standard flags', () => { + const cli = new CLI({ name: 'test' }); + const help = cli.helpText(); + + for (const env of ['ADDRESS', 'DEBUG', 'INSECURE', 'TLS_SERVER_CERTS_DIR', 'MAX_RECV_MESSAGE_SIZE']) { + expect(help).toContain(`[env: ${env}]`); + } + }); + }); + + describe('name', () => { + it('should return the configured program name', () => { + const cli = new CLI({ name: 'my-function' }); + expect(cli.name).toBe('my-function'); + }); + }); +}); diff --git a/src/cli/cli.ts b/src/cli/cli.ts new file mode 100644 index 0000000..4511244 --- /dev/null +++ b/src/cli/cli.ts @@ -0,0 +1,232 @@ +/** + * A shareable CLI for composition functions. + * + * The {@link CLI} class provides the standard flags every composition function + * accepts, reads environment variables, and lets function authors add custom + * flags. + * + * Simple functions that need no custom flags can keep using {@link serve}. + * Functions that need extra flags create a CLI, parse it, then pass the + * results to serve: + * + * @example + * ```typescript + * const cli = new CLI({ + * flags: { + * 'my-flag': { type: 'string', default: 'foo', env: 'MY_FLAG', description: 'My custom flag.' }, + * }, + * }); + * + * const parsed = cli.parse(); + * if (parsed.help) { + * process.stdout.write(cli.helpText() + '\n'); + * process.exit(0); + * } + * + * serve(compose, { argv: [], serverOptions: cli.standardOptions(), logger: cli.logger() }); + * ``` + */ + +import { basename } from 'node:path'; +import { parseArgs as parseNodeArgs } from 'node:util'; +import { pino, type Logger } from 'pino'; +import type { ServerOptions } from '../runtime/runtime.js'; + +const DEFAULT_ADDRESS = '0.0.0.0:9443'; +const DEFAULT_MAX_RECV_MESSAGE_SIZE = 4; +const DEFAULT_TLS_SERVER_CERTS_DIR = '/tls/server'; + +/** Specification for a single CLI flag. */ +export interface FlagSpec { + type: 'string' | 'boolean'; + default?: string | boolean; + short?: string; + env?: string; + description: string; +} + +const standardFlags: Record = { + address: { + type: 'string', + default: DEFAULT_ADDRESS, + env: 'ADDRESS', + description: `Address to listen for gRPC connections. Default ${DEFAULT_ADDRESS}.`, + }, + debug: { + type: 'boolean', + default: false, + short: 'd', + env: 'DEBUG', + description: 'Emit debug logs.', + }, + insecure: { + type: 'boolean', + default: false, + env: 'INSECURE', + description: 'Run without mTLS credentials.', + }, + 'max-recv-message-size': { + type: 'string', + default: String(DEFAULT_MAX_RECV_MESSAGE_SIZE), + env: 'MAX_RECV_MESSAGE_SIZE', + description: `Maximum size of received gRPC messages in MB. Default ${DEFAULT_MAX_RECV_MESSAGE_SIZE}.`, + }, + 'tls-server-certs-dir': { + type: 'string', + default: DEFAULT_TLS_SERVER_CERTS_DIR, + env: 'TLS_SERVER_CERTS_DIR', + description: `Directory holding tls.key, tls.crt and ca.crt. Default ${DEFAULT_TLS_SERVER_CERTS_DIR}.`, + }, + help: { + type: 'boolean', + default: false, + short: 'h', + description: 'Show this help.', + }, +}; + +/** Options accepted by the {@link CLI} constructor. */ +export interface CLIOptions { + /** Program name shown in help text. Defaults to the running script's basename. */ + name?: string; + + /** Custom flags to register alongside the standard ones. */ + flags?: Record; +} + +/** + * A shareable, extensible CLI for Crossplane composition functions. + * + * Provides the standard flags (address, debug, insecure, tls-server-certs-dir, + * max-recv-message-size, help) with environment variable support, and lets + * function authors register additional flags. + * + * Resolution order per flag: CLI argument > environment variable > default. + */ +export class CLI { + private readonly programName: string; + private readonly allFlags: Record; + private values: Record = {}; + + constructor(opts?: CLIOptions) { + this.programName = opts?.name ?? defaultName(); + + const custom = opts?.flags ?? {}; + for (const name of Object.keys(custom)) { + if (name in standardFlags) { + throw new Error(`flag "${name}" conflicts with a standard flag`); + } + } + this.allFlags = { ...standardFlags, ...custom }; + } + + /** + * Parse command-line arguments. + * + * For each flag the resolution order is: CLI argument, then environment + * variable (if configured), then the declared default. + * + * @param argv - Arguments without the node executable or script path. + * Defaults to `process.argv.slice(2)`. + * @returns All parsed values keyed by flag name. + * @throws If a flag is unrecognized, missing its value, or given a value it + * does not accept. + */ + parse(argv?: string[]): Record { + const args = argv ?? process.argv.slice(2); + + const parseOptions: Record = {}; + for (const [name, spec] of Object.entries(this.allFlags)) { + const opt: { type: string; short?: string; default?: boolean } = { type: spec.type }; + if (spec.short) opt.short = spec.short; + if (spec.type === 'boolean') opt.default = false; + parseOptions[name] = opt; + } + + const { values } = parseNodeArgs({ + args, + options: parseOptions as NonNullable[0]>['options'], + allowPositionals: false, + }); + + const parsed = values as Record; + + for (const [name, spec] of Object.entries(this.allFlags)) { + this.values[name] = resolveValue(spec, parsed[name]); + } + + return { ...this.values }; + } + + /** Return {@link ServerOptions} derived from the standard flags. */ + standardOptions(): ServerOptions { + const maxRecvMB = parseInt(String(this.values['max-recv-message-size']), 10); + return { + address: this.values['address'] as string, + debug: this.values['debug'] as boolean, + insecure: this.values['insecure'] as boolean, + tlsServerCertsDir: this.values['tls-server-certs-dir'] as string, + maxRecvMessageSize: + (isNaN(maxRecvMB) ? DEFAULT_MAX_RECV_MESSAGE_SIZE : maxRecvMB) * 1024 * 1024, + }; + } + + /** Return a pino {@link Logger} configured from the --debug flag. */ + logger(): Logger { + return pino({ + level: this.values['debug'] ? 'debug' : 'info', + formatters: { + level: (label: string) => ({ severity: label.toUpperCase() }), + }, + }); + } + + /** Generate help text that includes both standard and custom flags. */ + helpText(): string { + const usage = Object.entries(this.allFlags).map(([flag, spec]) => { + const short = spec.short ? `-${spec.short}, ` : ' '; + const value = spec.type === 'string' ? ' ' : ''; + const env = spec.env ? ` [env: ${spec.env}]` : ''; + return ` ${short}--${flag}${value}`.padEnd(38) + spec.description + env; + }); + return [ + `Usage: ${this.programName} [flags]`, + '', + 'A Crossplane composition function.', + '', + 'Flags:', + ...usage, + ].join('\n'); + } + + /** The program name used in help text. */ + get name(): string { + return this.programName; + } +} + +function resolveValue( + spec: FlagSpec, + cliValue: string | boolean | undefined +): string | boolean | undefined { + const givenOnCli = + spec.type === 'string' ? cliValue !== undefined : (cliValue as boolean) === true; + + if (givenOnCli) return cliValue; + + if (spec.env) { + const envValue = process.env[spec.env]; + if (envValue !== undefined) { + return spec.type === 'boolean' + ? envValue === '1' || envValue.toLowerCase() === 'true' + : envValue; + } + } + + return spec.default; +} + +function defaultName(): string { + const script = process.argv[1]; + return script ? basename(script) : 'function'; +} diff --git a/src/function/function.ts b/src/function/function.ts index 970f6d6..5436cc0 100644 --- a/src/function/function.ts +++ b/src/function/function.ts @@ -147,8 +147,16 @@ export class FunctionRunner { * const server = getServer(runner, logger); * ``` */ -export function getServer(functionRunner: FunctionRunner, logger: Logger): grpc.Server { - const server = new grpc.Server(); +export function getServer( + functionRunner: FunctionRunner, + logger: Logger, + opts?: { maxRecvMessageSize?: number } +): grpc.Server { + const channelOptions: grpc.ServerOptions = {}; + if (opts?.maxRecvMessageSize) { + channelOptions['grpc.max_receive_message_length'] = opts.maxRecvMessageSize; + } + const server = new grpc.Server(channelOptions); // Implement the service using the generated interface const implementation = { diff --git a/src/index.ts b/src/index.ts index 1d14ea7..4a13a96 100644 --- a/src/index.ts +++ b/src/index.ts @@ -61,6 +61,9 @@ export { update as updateResource, } from './resource/resource.js'; +// Shareable CLI +export { CLI, type CLIOptions, type FlagSpec } from './cli/cli.js'; + // Runtime utilities export { getServerCredentials, diff --git a/src/runtime/runtime.ts b/src/runtime/runtime.ts index c185975..3fbbfda 100644 --- a/src/runtime/runtime.ts +++ b/src/runtime/runtime.ts @@ -41,6 +41,12 @@ export interface ServerOptions { * Ignored if insecure is set to true. */ tlsServerCertsDir?: string; + + /** + * Maximum size of received gRPC messages in bytes. + * Default: 4 MB (4 * 1024 * 1024). + */ + maxRecvMessageSize?: number; } /** @@ -112,8 +118,12 @@ export function getServerCredentials(opts?: ServerOptions): grpc.ServerCredentia * startServer(server, { address: ":9443", insecure: false }, logger); * ``` */ -export function newGrpcServer(functionRunner: FunctionRunner, logger: Logger): grpc.Server { - const server = getServer(functionRunner, logger); +export function newGrpcServer( + functionRunner: FunctionRunner, + logger: Logger, + opts?: Pick +): grpc.Server { + const server = getServer(functionRunner, logger, opts); if (logger) { logger.debug('grpc server created'); } diff --git a/src/serve/serve.ts b/src/serve/serve.ts index bf2d3a5..3c14b55 100644 --- a/src/serve/serve.ts +++ b/src/serve/serve.ts @@ -266,7 +266,9 @@ export function serve(fn: ComposeFunction | FunctionHandler, opts: ServeOptions logger.debug({ options: serverOptions }, 'starting function'); const handler: FunctionHandler = typeof fn === 'function' ? fromCompose(fn) : fn; - const server = newGrpcServer(new FunctionRunner(handler, logger), logger); + const server = newGrpcServer(new FunctionRunner(handler, logger), logger, { + maxRecvMessageSize: serverOptions.maxRecvMessageSize, + }); startServer(server, serverOptions, logger); const shutdown = (signal: string): void => {