diff --git a/README.md b/README.md index d906d30..a606f4d 100644 --- a/README.md +++ b/README.md @@ -1,69 +1,109 @@ # Configliere -> [!NOTE] -> Configliere is undergoing a ground-up rebuild. The ideas below are working, -> but the API is not yet stable. +**A statically typed entry-point router for command-line applications.** -Configliere models a command-line program as a tree of typed routes. Parsing -does not merely produce a bag of options: it resolves an intent such as -`HELP /`, `VERSION /`, or `EXECUTE /serve` and, for execution, binds a model for -every route that led there. +An argument parser tells you what the user typed. Configliere tells you where +and how they intend to enter your program. For execution, it marshals a +validated, statically typed model for that entry point. -The architecture has four small pieces: +Configliere matches input to an intent: a method at an application-relative +route, such as: -- A **route** is an application-relative address such as `/` or `/serve`. -- A **method** says what to do with that route. Every route supports help; - `version()` adds version and `command()` creates an executable route. -- Each route owns its own **model**. An execute intent contains the current - `model` and a path-addressed `models` map for the entire matched lineage. -- Definitions are immutable composition pipelines. Elements such as - `description()`, `option()`, `toggle()`, `version()`, and `routes()` enrich - both the runtime definition and its TypeScript type. +```text +HELP / +VERSION / +EXECUTE /serve +HELP /database/clean +EXECUTE /database/clean +``` + +Every reachable intent appears in the result type. Narrow `method` and `route`, +and TypeScript knows the exact model available at that entry point. + +Configliere is not a CLI framework. It does not own handlers, effects, output, +or process lifetime. It is not a CLI parser whose product is a bag of flags. Its +product is a typed intent; your application decides what that intent does. + +```text +input → route → method → required binding and validation → intent +``` -Parameters are validated with [Standard Schema](https://standardschema.dev/), so -Configliere works with ArkType, Zod, Valibot, and other conforming schema -libraries. The examples below use ArkType. +## Define every way into the program -## One command, one model +`route()` declares an address. `command()` declares an executable route. Every +route supports help; version and execution exist only where they are explicitly +added. -`command()` is an executable route. Here it creates the root route `/` with -help, version, and execute methods: +Definitions are immutable composition pipelines, not handler registrations: ```ts -import process from "node:process"; import { command, description, name, option, - parse, - printHelp, - printVersion, + route, + routes, schema, toggle, version, } from "@frontside/configliere"; -import { type } from "arktype"; +import * as z from "zod"; -const app = command( - name("server"), - description("Run the HTTP server."), +export const app = command( + name("simulacrum"), + description("Run and manage local service simulators."), version("1.0.0"), - option( - name("port"), - description("Port to listen on."), - schema(type("number")), - ), - toggle( - name("verbose"), - description("Print request diagnostics."), + toggle(name("verbose")), + routes( + command( + name("serve"), + option(name("port"), schema(z.number().default(4000))), + ), + route( + name("database"), + routes( + command( + name("clean"), + toggle(name("dryRun")), + ), + ), + ), ), ); +``` + +That definition makes these entry points—and no others—reachable: + +```text +HELP / VERSION / EXECUTE / +HELP /serve EXECUTE /serve +HELP /database +HELP /database/clean EXECUTE /database/clean +``` + +The root name identifies the executable; it is not repeated in route IDs. +`simulacrum serve` therefore selects `/serve`, not `/simulacrum/serve`. + +## Route an intent + +`parse()` returns a discriminated union of the reachable entry points. Dispatch +can stay flat even when the route tree is deep: + +```ts +import process from "node:process"; +import { + parse, + printErrors, + printHelp, + printVersion, +} from "@frontside/configliere"; +import { app } from "./app.ts"; const result = parse(app, { argv: process.argv.slice(2) }); if (!result.ok) { - console.error(result.code, result); + console.error(printErrors(result)); process.exit(1); } @@ -71,142 +111,131 @@ switch (result.method) { case "help": console.log(printHelp(result)); break; + case "version": console.log(printVersion(result)); break; + case "execute": - // Exactly { port: number; verbose: boolean }, not Record. - serve(result.model); - break; + switch (result.route) { + case "/": + result.model.verbose; // boolean + break; + + case "/serve": + result.model.port; // number + result.models["/"].verbose; // boolean + break; + + case "/database/clean": + result.model.dryRun; // boolean + result.models["/"]; // { verbose: boolean } + result.models["/database"]; // {} + break; + } } +``` -function serve(model: { port: number; verbose: boolean }): void { - console.log(`listening on ${model.port}; verbose=${model.verbose}`); -} +An execute intent has two views of configuration: + +- `model` is owned by the selected route. +- `models` contains the statically typed model for every route along the + selected path. Sibling routes are absent from both the value and its type. + +| Invocation | Intent and model | +| ------------------------------------- | ------------------------------------------------------------------- | +| `simulacrum --verbose` | `EXECUTE /` with `{ verbose: true }` | +| `simulacrum serve --port 4100` | `EXECUTE /serve` with `{ port: 4100 }` | +| `simulacrum database clean --dry-run` | `EXECUTE /database/clean` with `{ dryRun: true }` | +| `simulacrum --help database clean` | `HELP /database/clean`; controls target the deepest selected route | +| `simulacrum database` | `method-not-allowed`; `/database` does not support execution | +| `simulacrum serve --port nope` | `unprocessable-content`; invalid data never reaches the application | + +Command literals are routing tokens, not positional arguments. Configliere +selects the route first, then binds options to the route segment that owns them. +This makes identical option names on parent and child routes unambiguous. + +## Marshal configuration into the route + +CLI arguments are only one source. Configliere can marshal JavaScript values and +flat environment records into the same route-local models: + +```ts +const result = parse(app, { + argv: process.argv.slice(2), + values: [{ + name: "config.json", + value: { serve: { port: 4200 } }, + }], + envs: [{ + name: "process", + value: process.env, + }], +}); ``` -`parse()` receives the arguments _after_ the executable name, which is why the -example passes `process.argv.slice(2)`. The full command lines above resolve as -follows: +For `/serve`, `serve.port` and `SERVE_PORT` both address its `port` parameter. +CLI text and environment text are decoded; JavaScript values are used directly. +The resulting value is then validated by its +[Standard Schema](https://standardschema.dev/) schema. Zod, ArkType, Valibot, +and other conforming libraries work without adapters. -| Invocation | Result | -| ------------------------------ | ---------------------------------------------------------------------------- | -| `server --help` | `HELP /`; `printHelp()` renders the route's usage, options, and descriptions | -| `server --version` | `VERSION /`; `printVersion()` returns `server 1.0.0` | -| `server --port 4000` | `EXECUTE /`; `model` is `{ port: 4000, verbose: false }` | -| `server --port=4000 --verbose` | `EXECUTE /`; `model` is `{ port: 4000, verbose: true }` | -| `server --port nope` | `unprocessable-content`; execution never receives an invalid model | +Source precedence is explicit: -The method is a discriminant. Once the switch reaches `"execute"`, TypeScript -knows that `model` exists and has exactly the shape assembled by the command's -parameters. Help and version are resolved before execution validation, so -`server --help` does not require a port. +```text +CLI → environment → JavaScript values → schema default +``` -## Add commands, keep dispatch flat +## Pause without surrendering the type system -Routes form a tree, but callers do not have to mirror that tree with nested -conditionals. `IntentsOf` is a flat union of every method and route -the definition can reach, so `result.route` is another useful discriminant. +Sometimes the route cannot be fully configured—or even fully discovered—until +the application performs I/O. Configliere can pause at a typed checkpoint and +resume with the result: ```ts import process from "node:process"; import { + checkpoint, command, name, option, parse, - printHelp, - routes, + type Result, schema, - toggle, + type ValueSource, } from "@frontside/configliere"; -import { type } from "arktype"; +import * as z from "zod"; const app = command( - name("simulacrum"), - toggle(name("verbose")), - routes( - command( - name("serve"), - option(name("port"), schema(type("number"))), - ), - command( - name("inspect"), - toggle(name("json")), - ), - ), + name("server"), + option(name("config"), schema(z.string())), + checkpoint(), + option(name("port"), schema(z.number())), ); -const result = parse(app, { argv: process.argv.slice(2) }); +const step = parse(app, { argv: process.argv.slice(2) }); -if (!result.ok) { - console.error(result.code, result); +if (!step.ok) { process.exit(1); } -if (result.method === "help") { - console.log(printHelp(result)); -} else { - switch (result.route) { - case "/": - // model: { verbose: boolean } - // models: { "/": { verbose: boolean } } - start(result.model); - break; - - case "/serve": - // model is only the selected route's model. - result.model.port; // number - - // models contains every model on the matched route lineage. - result.models["/"].verbose; // boolean - result.models["/serve"].port; // number - - // @ts-expect-error: /inspect was not part of this match. - result.models["/inspect"]; - serve(result.model, result.models["/"]); - break; - - case "/inspect": - // model: { json: boolean } - // models: { "/": { verbose: boolean }, "/inspect": { json: boolean } } - inspect(result.model, result.models["/"]); - break; - } -} +step.model.config; // string—the model resolved before the checkpoint -function start(model: { verbose: boolean }): void { - console.log(`simulacrum; verbose=${model.verbose}`); -} +const loaded = await load(step.model.config); +const result = step.resume(loaded); -function serve( - model: { port: number }, - root: { verbose: boolean }, -): void { - console.log(`serving on ${model.port}; verbose=${root.verbose}`); +if (result.ok && result.method === "execute") { + result.model; // { config: string; port: number } } -function inspect( - model: { json: boolean }, - root: { verbose: boolean }, -): void { - console.log(`inspecting; json=${model.json}; verbose=${root.verbose}`); -} +declare function load(path: string): Promise>; ``` -| Invocation | Result | -| ---------------------------------------- | ---------------------------------------------------------------------------------------------------- | -| `simulacrum --verbose` | `EXECUTE /`; `model` and `models["/"]` are `{ verbose: true }` | -| `simulacrum --verbose serve --port 4000` | `EXECUTE /serve`; `model` is `{ port: 4000 }`, while `models` also contains `"/": { verbose: true }` | -| `simulacrum inspect --json` | `EXECUTE /inspect`; `model` is `{ json: true }` and the root model has `{ verbose: false }` | -| `simulacrum serve --help` | `HELP /serve`; no root or command model is validated | - -Route tokens select the deepest matching route; they are not positional -arguments. Options on each segment bind only to the model owned by that route. -The root definition's name identifies the executable but is not repeated in -route IDs: the root is `/`, not `/simulacrum`. - -For a deeper route such as `/database/clean`, the same rule applies: -`result.model` is the clean command's model, while `result.models` has exact -entries for `/`, `/database`, and `/database/clean`. Sibling models are absent -at runtime and from the TypeScript type. +The parser remains synchronous and performs no I/O. The caller loads the file +and resumes with a `Result`; loader failures enter the ordinary issue path. +Unconsumed CLI input survives the pause, so a later `--port 5000` can override +the value loaded from the file. + +The same phase mechanism can add options or routes from runtime data. Parsing +then continues against the expanded route graph, and the continuation type +describes the entry points that can appear next. diff --git a/docs/binding.md b/docs/binding.md index 61735fd..9a7f38f 100644 --- a/docs/binding.md +++ b/docs/binding.md @@ -14,8 +14,8 @@ structure: ```text CLI fixed point -Values Env +Values undefined ``` @@ -271,7 +271,7 @@ function bindPhase(options: BindPhaseOptions): PhaseBinding { } // Stable address sources: array order is precedence. - for (let source of [fromValues, fromEnv]) { + for (let source of [fromEnv, fromValues]) { for (let param of pending.values()) { accept( param, @@ -354,9 +354,9 @@ The design should be established with observable tests for these cases: parameters. - A stable horizon leaves the entire suffix untouched. - CLI overrides Values even when its option is not visible until a later sweep. -- Values override Env when that is the declared source order. -- Invalid CLI blocks valid Values and Env. -- Invalid Values block valid Env. +- Env overrides Values when that is the declared source order. +- Invalid CLI blocks valid Env and Values. +- Invalid Env blocks valid Values. - Only total absence invokes required, optional, or defaulting schema behavior. - A dynamically introduced child receives every token after its selector. - Sources introduced by a phase become available at that phase and persist diff --git a/lib/bind.ts b/lib/bind.ts index 2a7a9e6..b2a7185 100644 --- a/lib/bind.ts +++ b/lib/bind.ts @@ -96,6 +96,36 @@ export function fromValues(options: { }; } +export function fromEnv(options: { + readonly param: Param; + readonly route: Path; + readonly rest: Rest; +}): Maybe> { + let { param, route, rest } = options; + let claim = rest.envs.claim({ + route, + address: [param.name], + key: param.env, + }); + + if (!claim.result.exists) { + return { exists: false }; + } + + let value = claim.result.value.value; + + return { + exists: true, + value: { + rest: { + ...rest, + envs: claim.rest, + }, + result: decode(param, value, param.decode(value), [param.name]), + }, + }; +} + export function bindPhase(options: { readonly phase: AnyPhase; readonly segment: PhaseSegment; @@ -148,7 +178,7 @@ export function bindPhase(options: { // Address sources have stable visibility once the route is known. They are // tried only after CLI has reached its fixed point so a provisional CLI miss // cannot let a lower-priority source settle the parameter too early. - for (let source of [fromValues]) { + for (let source of [fromEnv, fromValues]) { for (let param of pending.values()) { accept( param, diff --git a/lib/decode.ts b/lib/decode.ts index c88f36b..674b645 100644 --- a/lib/decode.ts +++ b/lib/decode.ts @@ -13,4 +13,14 @@ export const scalar: Decoder = (value) => { return [...number(value), value]; }; +export const boolean: Decoder = (value) => { + if (value === "true") { + return [true]; + } + if (value === "false") { + return [false]; + } + return []; +}; + const numeric = /^-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?$/; diff --git a/lib/dynamic.ts b/lib/dynamic.ts index 7244f75..37b37b9 100644 --- a/lib/dynamic.ts +++ b/lib/dynamic.ts @@ -28,6 +28,7 @@ export function dynamic< params: {}, routes: [], values: [], + envs: [], }); return { ...route, phases } as unknown as Conjoin< diff --git a/lib/env.ts b/lib/env.ts new file mode 100644 index 0000000..f5277f8 --- /dev/null +++ b/lib/env.ts @@ -0,0 +1,137 @@ +import type { Maybe } from "./maybe.ts"; +import type { Param } from "./param.ts"; +import type { AnyRoute, RoutePath } from "./types.ts"; + +export interface EnvSource { + readonly name: string; + readonly value: Environment; +} + +export type Environment = Readonly>; + +export interface EnvClaim { + readonly result: Maybe<{ + readonly source: string; + readonly address: readonly string[]; + readonly key: string; + readonly value: string; + }>; + readonly rest: Envs; +} + +export interface EnvClaimOptions { + readonly route: readonly string[]; + readonly address: readonly string[]; + readonly key?: string; +} + +export function env( + key: string, +):

>(param: P) => P { + return (param) => ({ ...param, env: key }); +} + +export function withEnvs( + envs: readonly EnvSource[], +): (route: R) => R { + return (route) => { + let phases = [...route.phases]; + let phase = phases.pop()!; + phases.push({ + ...phase, + envs: phase.envs.concat(envs), + }); + return { + ...route, + phases, + }; + }; +} + +export class Envs { + mounts: Map; + claims: Set; + + constructor( + mounts: Map = new Map(), + claims: Set = new Set(), + ) { + this.mounts = mounts; + this.claims = claims; + } + + mount(path: readonly string[], sources: readonly EnvSource[]): Envs { + if (sources.length === 0) { + return this; + } + let id = routeId(path); + + return new Envs( + new Map([...this.mounts.entries(), [ + id, + (this.mounts.get(id) ?? []).concat(sources), + ]]), + this.claims, + ); + } + + claim( + { route, address, key = envKey([...route, ...address]) }: EnvClaimOptions, + ): EnvClaim { + let id = claimId([...route, ...address]); + let nope: EnvClaim = { result: { exists: false }, rest: this }; + if (this.claims.has(id)) { + return nope; + } + + for (let end = route.length; end >= 0; end--) { + let mount = routeId(route.slice(0, end)); + let sources = this.mounts.get(mount) ?? []; + + for (let { name, value } of sources) { + if (!Object.hasOwn(value, key) || typeof value[key] === "undefined") { + continue; + } + + let rest = new Envs(this.mounts, new Set([...this.claims, id])); + return { + result: { + exists: true, + value: { + source: name, + address, + key, + value: value[key], + }, + }, + rest, + }; + } + } + + return nope; + } +} + +type ClaimId = string; + +function routeId(path: readonly string[]): RoutePath { + return `/${path.join("/")}`; +} + +function claimId(address: readonly string[]): ClaimId { + return JSON.stringify(address); +} + +function envKey(address: readonly string[]): string { + return address.map(normalize).filter(Boolean).join("_"); +} + +function normalize(value: string): string { + return value + .replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2") + .replace(/([a-z\d])([A-Z])/g, "$1_$2") + .replace(/[^A-Za-z\d]+/g, "_") + .replace(/^_+|_+$/g, "") + .toUpperCase(); +} diff --git a/lib/param.ts b/lib/param.ts index fd84672..0897d19 100644 --- a/lib/param.ts +++ b/lib/param.ts @@ -9,6 +9,7 @@ export interface Param extends Definition { schema: Schema; cli: ReadCLI; decode: Decoder; + env?: string; } export function param( diff --git a/lib/parse.ts b/lib/parse.ts index 299ef65..9e587fe 100644 --- a/lib/parse.ts +++ b/lib/parse.ts @@ -1,4 +1,5 @@ import { bindPhase } from "./bind.ts"; +import { Envs } from "./env.ts"; import type { Symbol } from "./read.ts"; import type { Rest } from "./rest.ts"; import type { Result } from "./result.ts"; @@ -47,6 +48,7 @@ export function parse( let rest: Rest = { tokens: literals.rest as Tokenizer, values: new Values().mount([], input.values ?? []), + envs: new Envs().mount([], input.envs ?? []), }; return resume({ @@ -85,6 +87,7 @@ function resume( rest: { ...state.rest, values: state.rest.values.mount(segment.path, phase.values), + envs: state.rest.envs.mount(segment.path, phase.envs), }, }; @@ -351,6 +354,10 @@ function stitch( ...phase.values, ...next.values, ], + envs: [ + ...phase.envs, + ...next.envs, + ], }); phases.push(...rest); @@ -415,6 +422,7 @@ function seed(route: AnyRoute): AnyRoute { params: {}, routes: [], values: [], + envs: [], }], }; } diff --git a/lib/rest.ts b/lib/rest.ts index 34c64b8..a9c66fe 100644 --- a/lib/rest.ts +++ b/lib/rest.ts @@ -1,3 +1,4 @@ +import type { Envs } from "./env.ts"; import type { Symbol } from "./read.ts"; import type { Tokenizer } from "./tokenizer.ts"; import type { Values } from "./values.ts"; @@ -5,4 +6,5 @@ import type { Values } from "./values.ts"; export interface Rest { readonly tokens: Tokenizer; readonly values: Values; + readonly envs: Envs; } diff --git a/lib/route.ts b/lib/route.ts index 9a8e39c..fa73678 100644 --- a/lib/route.ts +++ b/lib/route.ts @@ -1113,6 +1113,7 @@ export function route( params: {}, routes: [], values: [], + envs: [], }], }; return extend(elements)(zero); diff --git a/lib/toggle.ts b/lib/toggle.ts index 9bf5580..8faaf9f 100644 --- a/lib/toggle.ts +++ b/lib/toggle.ts @@ -1,3 +1,4 @@ +import { boolean as decode } from "./decode.ts"; import { dasherize } from "./dasherize.ts"; import { type Param, param, schema } from "./param.ts"; import type { CLIRead, ReadCLI } from "./read.ts"; @@ -124,7 +125,10 @@ export function toggle( ): unknown { const added = elements.reduce( (value, element) => element(value as never), - param(named, binding(named.name), schema(bool)), + { + ...param(named, binding(named.name), schema(bool)), + decode, + }, ) as Param; return (route: AnyRoute) => { diff --git a/lib/types.ts b/lib/types.ts index ba95b11..1076880 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -1,4 +1,5 @@ import type { StandardSchemaV1 } from "@standard-schema/spec"; +import type { EnvSource } from "./env.ts"; import type { Literal } from "./tokenize.ts"; import type { Param } from "./param.ts"; import type { Result } from "./result.ts"; @@ -43,6 +44,7 @@ export type Next< readonly params: Params; readonly routes: Routes; readonly values: readonly ValueSource[]; + readonly envs: readonly EnvSource[]; readonly resolver: ( requirement: T, ) => (input: AnyRoute) => AnyRoute; @@ -55,6 +57,7 @@ export type Done< readonly params: Params; readonly routes: Routes; readonly values: readonly ValueSource[]; + readonly envs: readonly EnvSource[]; }; export type Params = { @@ -121,6 +124,7 @@ export interface AnyPhase { readonly params: Params; readonly routes: readonly AnyRoute[]; readonly values: readonly ValueSource[]; + readonly envs: readonly EnvSource[]; readonly resolver?: (requirement: never) => (route: never) => AnyRoute; } @@ -134,6 +138,7 @@ export type Path = readonly string[]; export type Input = { argv: string[]; values?: readonly ValueSource[]; + envs?: readonly EnvSource[]; }; export interface Failure { diff --git a/mod.ts b/mod.ts index 99507c0..70c8e76 100644 --- a/mod.ts +++ b/mod.ts @@ -6,6 +6,9 @@ export { checkpoint } from "./lib/checkpoint.ts"; export { description, name } from "./lib/definition.ts"; +export { env, withEnvs } from "./lib/env.ts"; +export type { Environment, EnvSource } from "./lib/env.ts"; + export { option } from "./lib/option.ts"; export { param, schema } from "./lib/param.ts"; diff --git a/test/bind.test.ts b/test/bind.test.ts index 5925465..5555b71 100644 --- a/test/bind.test.ts +++ b/test/bind.test.ts @@ -3,6 +3,7 @@ import { describe, it } from "@std/testing/bdd"; import * as z from "zod"; import { type Binding, bindPhase, fromCLI } from "../lib/bind.ts"; import { name } from "../lib/definition.ts"; +import { Envs } from "../lib/env.ts"; import type { Maybe } from "../lib/maybe.ts"; import { type Param, param, schema } from "../lib/param.ts"; import { cli, type Symbol } from "../lib/read.ts"; @@ -161,6 +162,7 @@ describe("binding", () => { let rest: Rest = { tokens: symbols(["--port", "9001"]), values, + envs: new Envs(), }; let attempt = fromCLI({ param: param( @@ -270,6 +272,7 @@ function state(argv: string[]): Rest { return { tokens: symbols(argv), values: new Values(), + envs: new Envs(), }; } @@ -278,6 +281,7 @@ function phase(params: Record>) { params, routes: [], values: [], + envs: [], }; } diff --git a/test/decode.test.ts b/test/decode.test.ts index 3fe30d0..3148cf7 100644 --- a/test/decode.test.ts +++ b/test/decode.test.ts @@ -1,6 +1,6 @@ import { expect } from "@std/expect"; import { describe, it } from "@std/testing/bdd"; -import { number, scalar } from "../lib/decode.ts"; +import { boolean, number, scalar } from "../lib/decode.ts"; describe("decoders", () => { it("offers numeric and text interpretations of a numeral", () => { @@ -10,4 +10,10 @@ describe("decoders", () => { it("returns no candidate when a number cannot be decoded", () => { expect(number("twelve")).toEqual([]); }); + + it("decodes boolean text without treating other text as boolean", () => { + expect(boolean("true")).toEqual([true]); + expect(boolean("false")).toEqual([false]); + expect(boolean("yes")).toEqual([]); + }); }); diff --git a/test/dynamic.test.ts b/test/dynamic.test.ts index 4a82c8f..d08741c 100644 --- a/test/dynamic.test.ts +++ b/test/dynamic.test.ts @@ -749,30 +749,6 @@ Options: }); }); }); - - describe("future sources", () => { - it.skip("lets a later phase claim an existing environment source", () => { - // let app = command( - // name("simulacrum"), - // dynamic((_config: Config) => - // extend(option(name("b"), env("B"))) - // ), - // ); - // let first = parse(app, { - // argv: [], - // envs: [{ name: "process", value: { B: "two" } }], - // }); - // increment(first, {}); - // let result = first.resume({ - // ok: true, - // value: { services: [] }, - // }); - // expect(result).toMatchObject({ - // method: "execute", - // model: { b: "two" }, - // }); - }); - }); }); }); diff --git a/test/env.test.ts b/test/env.test.ts new file mode 100644 index 0000000..4422eb6 --- /dev/null +++ b/test/env.test.ts @@ -0,0 +1,730 @@ +import { expect } from "@std/expect"; +import { describe, it } from "@std/testing/bdd"; +import * as z from "zod"; +import { command } from "../lib/command.ts"; +import { name } from "../lib/definition.ts"; +import { dynamic } from "../lib/dynamic.ts"; +import { env, withEnvs } from "../lib/env.ts"; +import { extend } from "../lib/extend.ts"; +import { option } from "../lib/option.ts"; +import { schema } from "../lib/param.ts"; +import { parse } from "../lib/parse.ts"; +import { route, routes } from "../lib/route.ts"; +import { toggle } from "../lib/toggle.ts"; +import type { ModelOf } from "../lib/types.ts"; + +describe("environment binding", () => { + describe("mapping", () => { + it("maps a root parameter to its normalized environment name", () => { + let app = command( + name("simulacrum"), + option(name("port"), schema(z.number())), + ); + let result = parse(app, { + argv: [], + envs: [{ name: "process", value: { PORT: "9001" } }], + }); + + expect(result).toMatchObject({ + ok: true, + method: "execute", + route: "/", + model: { port: 9001 }, + }); + }); + + it("prefixes a parameter with its complete route path", () => { + let clean = command( + name("clean"), + option(name("dryRun"), schema(z.string())), + ); + let database = route(name("database"), routes(clean)); + let app = command(name("simulacrum"), routes(database)); + let result = parse(app, { + argv: ["database", "clean"], + envs: [{ + name: "process", + value: { DATABASE_CLEAN_DRY_RUN: "yes" }, + }], + }); + + expect(result).toMatchObject({ + ok: true, + method: "execute", + route: "/database/clean", + model: { dryRun: "yes" }, + }); + }); + + it("normalizes acronyms and punctuation", () => { + let clean = command( + name("pg:clean"), + option(name("clientID"), schema(z.string())), + ); + let app = command(name("simulacrum"), routes(clean)); + let result = parse(app, { + argv: ["pg:clean"], + envs: [{ + name: "process", + value: { PG_CLEAN_CLIENT_ID: "0012" }, + }], + }); + + expect(result).toMatchObject({ + ok: true, + method: "execute", + route: "/pg:clean", + model: { clientID: "0012" }, + }); + }); + + it("lets an explicit environment mapping replace the default", () => { + let app = command( + name("simulacrum"), + option( + name("port"), + env("HTTP_PORT"), + schema(z.number()), + ), + ); + let result = parse(app, { + argv: [], + envs: [{ + name: "process", + value: { PORT: "9001", HTTP_PORT: "9002" }, + }], + }); + + expect(result).toMatchObject({ + ok: true, + method: "execute", + route: "/", + model: { port: 9002 }, + }); + }); + + it("does not retain the inferred environment name as an alias", () => { + let app = command( + name("simulacrum"), + option( + name("port"), + schema(z.number()), + env("HTTP_PORT"), + ), + ); + let result = parse(app, { + argv: [], + envs: [{ name: "process", value: { PORT: "9001" } }], + }); + + expect(result).toMatchObject({ + ok: false, + code: "unprocessable-content", + route: "/", + issues: [{ path: ["port"] }], + }); + }); + + it("does not change the inferred model type", () => { + let app = command( + name("simulacrum"), + option( + name("port"), + env("HTTP_PORT"), + schema(z.number()), + ), + ); + + expectType, { port: number }>>(true); + }); + }); + + describe("decoding", () => { + it("decodes environment text with the parameter decoder", () => { + let app = command( + name("simulacrum"), + option(name("port"), schema(z.number())), + ); + let result = parse(app, { + argv: [], + envs: [{ name: "process", value: { PORT: "9001" } }], + }); + + expect(result).toMatchObject({ + ok: true, + method: "execute", + model: { port: 9001 }, + }); + }); + + it("tries later decoder candidates", () => { + let app = command( + name("simulacrum"), + option(name("clientID"), schema(z.string())), + ); + let result = parse(app, { + argv: [], + envs: [{ name: "process", value: { CLIENT_ID: "0012" } }], + }); + + expect(result).toMatchObject({ + ok: true, + method: "execute", + model: { clientID: "0012" }, + }); + }); + + it("reports schema issues for an invalid decoded value", () => { + let app = command( + name("simulacrum"), + option(name("port"), schema(z.number())), + ); + let result = parse(app, { + argv: [], + envs: [{ name: "process", value: { PORT: "nope" } }], + }); + + expect(result).toMatchObject({ + ok: false, + code: "unprocessable-content", + route: "/", + issues: [{ path: ["port"] }], + }); + }); + + it("treats an empty string as present", () => { + let app = command( + name("simulacrum"), + option(name("domain"), schema(z.string().default("localhost"))), + ); + let result = parse(app, { + argv: [], + envs: [{ name: "process", value: { DOMAIN: "" } }], + }); + + expect(result).toMatchObject({ + ok: true, + method: "execute", + model: { domain: "" }, + }); + }); + + it("decodes environment text for a toggle", () => { + let app = command( + name("simulacrum"), + toggle(name("verbose")), + ); + let result = parse(app, { + argv: [], + envs: [{ name: "process", value: { VERBOSE: "true" } }], + }); + + expect(result).toMatchObject({ + ok: true, + method: "execute", + model: { verbose: true }, + }); + }); + }); + + describe("absence", () => { + it("validates undefined when no source contains the key", () => { + let app = command( + name("simulacrum"), + option(name("port"), schema(z.number())), + ); + let result = parse(app, { + argv: [], + envs: [{ name: "process", value: {} }], + }); + + expect(result).toMatchObject({ + ok: false, + code: "unprocessable-content", + route: "/", + issues: [{ path: ["port"] }], + }); + }); + + it("lets an absent environment value activate a schema default", () => { + let app = command( + name("simulacrum"), + option(name("port"), schema(z.number().default(9000))), + ); + let result = parse(app, { + argv: [], + envs: [{ name: "process", value: {} }], + }); + + expect(result).toMatchObject({ + ok: true, + method: "execute", + model: { port: 9000 }, + }); + }); + + it("treats an undefined process environment entry as absent", () => { + let app = command( + name("simulacrum"), + option(name("port"), schema(z.number())), + ); + let result = parse(app, { + argv: [], + envs: [ + { name: "process", value: { PORT: undefined } }, + { name: "defaults", value: { PORT: "9001" } }, + ], + }); + + expect(result).toMatchObject({ + ok: true, + method: "execute", + model: { port: 9001 }, + }); + }); + }); + + describe("precedence", () => { + it("prefers CLI over environment values", () => { + let app = command( + name("simulacrum"), + option(name("port"), schema(z.number())), + ); + let result = parse(app, { + argv: ["--port", "9002"], + envs: [{ name: "process", value: { PORT: "nope" } }], + }); + + expect(result).toMatchObject({ + ok: true, + method: "execute", + model: { port: 9002 }, + issues: [], + }); + }); + + it("prefers environment values over JavaScript values", () => { + let app = command( + name("simulacrum"), + option(name("port"), schema(z.number())), + ); + let result = parse(app, { + argv: [], + values: [{ name: "settings", value: { port: 9002 } }], + envs: [{ name: "process", value: { PORT: "9001" } }], + }); + + expect(result).toMatchObject({ + ok: true, + method: "execute", + model: { port: 9001 }, + issues: [], + }); + }); + + it("does not hide an invalid environment value with a JavaScript value", () => { + let app = command( + name("simulacrum"), + option(name("port"), schema(z.number())), + ); + let result = parse(app, { + argv: [], + values: [{ name: "settings", value: { port: 9002 } }], + envs: [{ name: "process", value: { PORT: "nope" } }], + }); + + expect(result).toMatchObject({ + ok: false, + code: "unprocessable-content", + route: "/", + issues: [{ path: ["port"] }], + }); + }); + + it("does not hide an incomplete CLI option with an environment value", () => { + let app = command( + name("simulacrum"), + option(name("port"), schema(z.number())), + ); + let result = parse(app, { + argv: ["--port"], + envs: [{ name: "process", value: { PORT: "9001" } }], + }); + + expect(result).toMatchObject({ + ok: false, + code: "unprocessable-content", + route: "/", + issues: [{ message: "--port requires a value" }], + }); + }); + + it("uses the first environment source containing the key", () => { + let app = command( + name("simulacrum"), + option(name("port"), schema(z.number())), + ); + let result = parse(app, { + argv: [], + envs: [ + { name: "settings", value: { PORT: "9001" } }, + { name: "defaults", value: { PORT: "9002" } }, + ], + }); + + expect(result).toMatchObject({ + ok: true, + method: "execute", + model: { port: 9001 }, + }); + }); + + it("tries the next environment source when the key is absent", () => { + let app = command( + name("simulacrum"), + option(name("port"), schema(z.number())), + ); + let result = parse(app, { + argv: [], + envs: [ + { name: "settings", value: { HOST: "localhost" } }, + { name: "defaults", value: { PORT: "9002" } }, + ], + }); + + expect(result).toMatchObject({ + ok: true, + method: "execute", + model: { port: 9002 }, + }); + }); + + it("does not hide an invalid environment value with a later source", () => { + let app = command( + name("simulacrum"), + option(name("port"), schema(z.number())), + ); + let result = parse(app, { + argv: [], + envs: [ + { name: "settings", value: { PORT: "nope" } }, + { name: "defaults", value: { PORT: "9002" } }, + ], + }); + + expect(result).toMatchObject({ + ok: false, + code: "unprocessable-content", + route: "/", + issues: [{ path: ["port"] }], + }); + }); + + it("does not replace an invalid environment value with a default", () => { + let app = command( + name("simulacrum"), + option(name("port"), schema(z.number().default(9000))), + ); + let result = parse(app, { + argv: [], + envs: [{ name: "process", value: { PORT: "nope" } }], + }); + + expect(result).toMatchObject({ + ok: false, + code: "unprocessable-content", + route: "/", + issues: [{ path: ["port"] }], + }); + }); + }); + + describe("mounting", () => { + it("binds sources introduced by withEnvs()", () => { + let app = command( + name("simulacrum"), + withEnvs([{ + name: "defaults", + value: { PORT: "9001" }, + }]), + option(name("port"), schema(z.number())), + ); + let result = parse(app, { argv: [] }); + + expect(result).toMatchObject({ + ok: true, + method: "execute", + route: "/", + model: { port: 9001 }, + }); + }); + + it("prefers a source mounted on the current route over an ancestor", () => { + let auth0 = command( + name("auth0"), + withEnvs([{ + name: "auth0.json", + value: { AUTH0_PORT: "9003" }, + }]), + option(name("port"), schema(z.number())), + ); + let app = command( + name("simulacrum"), + withEnvs([{ + name: "settings", + value: { AUTH0_PORT: "9001" }, + }]), + routes(auth0), + ); + let result = parse(app, { argv: ["auth0"] }); + + expect(result).toMatchObject({ + ok: true, + method: "execute", + route: "/auth0", + model: { port: 9003 }, + }); + }); + + it("falls back to an ancestor source when the local key is absent", () => { + let auth0 = command( + name("auth0"), + withEnvs([{ + name: "auth0.json", + value: { AUTH0_DOMAIN: "auth0.local" }, + }]), + option(name("port"), schema(z.number())), + ); + let app = command( + name("simulacrum"), + withEnvs([{ + name: "settings", + value: { AUTH0_PORT: "9001" }, + }]), + routes(auth0), + ); + let result = parse(app, { argv: ["auth0"] }); + + expect(result).toMatchObject({ + ok: true, + method: "execute", + route: "/auth0", + model: { port: 9001 }, + }); + }); + + it("lets distinct target addresses read the same environment key", () => { + let auth0 = command( + name("auth0"), + option( + name("secondary"), + env("PORT"), + schema(z.number()), + ), + ); + let app = command( + name("simulacrum"), + option( + name("primary"), + env("PORT"), + schema(z.number()), + ), + routes(auth0), + ); + let result = parse(app, { + argv: ["auth0"], + envs: [{ name: "process", value: { PORT: "9001" } }], + }); + + expect(result).toMatchObject({ + ok: true, + method: "execute", + route: "/auth0", + models: { + "/": { primary: 9001 }, + "/auth0": { secondary: 9001 }, + }, + }); + }); + + it("ignores unconsumed environment keys", () => { + let app = command(name("simulacrum")); + let result = parse(app, { + argv: [], + envs: [{ + name: "process", + value: { UNRELATED: "value" }, + }], + }); + + expect(result).toMatchObject({ + ok: true, + method: "execute", + route: "/", + model: {}, + }); + }); + }); + + describe("dynamic phases", () => { + it("keeps input environment sources available to later parameters", () => { + let app = command( + name("simulacrum"), + dynamic((_plugins: Plugins) => + extend( + option(name("domain"), schema(z.string())), + ) + ), + ); + let increment = parse(app, { + argv: [], + envs: [{ + name: "process", + value: { DOMAIN: "auth0.local" }, + }], + }); + + assertIncrement(increment); + let result = increment.resume({ + ok: true, + value: { names: ["auth0"] }, + }); + + expect(result).toMatchObject({ + ok: true, + method: "execute", + route: "/", + model: { domain: "auth0.local" }, + }); + }); + + it("uses the final path when mapping a dynamically introduced route", () => { + let auth0 = command( + name("auth0"), + option(name("port"), schema(z.number())), + ); + let app = command( + name("simulacrum"), + dynamic((_plugins: Plugins) => extend(routes(auth0))), + ); + let increment = parse(app, { + argv: ["auth0"], + envs: [{ + name: "process", + value: { AUTH0_PORT: "9001" }, + }], + }); + + assertIncrement(increment); + let result = increment.resume({ + ok: true, + value: { names: ["auth0"] }, + }); + + expect(result).toMatchObject({ + ok: true, + method: "execute", + route: "/auth0", + model: { port: 9001 }, + }); + }); + + it("keeps dynamically introduced environment sources available downstream", () => { + let app = command( + name("simulacrum"), + dynamic((_plugins: Plugins) => + extend( + withEnvs([{ + name: "settings", + value: { DOMAIN: "auth0.local" }, + }]), + dynamic((_services: Plugins) => + extend( + option(name("domain"), schema(z.string())), + ) + ), + ) + ), + ); + let first = parse(app, { argv: [] }); + + assertIncrement(first); + let second = first.resume({ + ok: true, + value: { names: ["config"] }, + }); + + assertIncrement(second); + let result = second.resume({ + ok: true, + value: { names: ["auth0"] }, + }); + + expect(result).toMatchObject({ + ok: true, + method: "execute", + route: "/", + model: { domain: "auth0.local" }, + }); + }); + + it("binds environment input needed to discover a help route", () => { + let auth0 = command(name("auth0")); + let app = command( + name("simulacrum"), + option(name("config"), schema(z.string())), + dynamic((_plugins: Plugins) => extend(routes(auth0))), + ); + let increment = parse(app, { + argv: ["auth0", "--help"], + envs: [{ + name: "process", + value: { CONFIG: "plugins.json" }, + }], + }); + + assertIncrement(increment, { config: "plugins.json" }); + let result = increment.resume({ + ok: true, + value: { names: ["auth0"] }, + }); + + expect(result).toMatchObject({ + ok: true, + method: "help", + route: "/auth0", + }); + }); + }); +}); + +interface Plugins { + readonly names: readonly string[]; +} + +type Equal = (() => T extends L ? 1 : 2) extends + (() => T extends R ? 1 : 2) + ? (() => T extends R ? 1 : 2) extends (() => T extends L ? 1 : 2) ? true + : false + : false; + +function expectType(_value: T): void { + // Compile-time assertion. +} + +function assertIncrement( + result: T, + model: object = {}, +): asserts result is Extract< + T, + { readonly ok: true; readonly resume: unknown } +> { + expect(result).toMatchObject({ + ok: true, + route: "/", + model, + }); + expect( + typeof (result as { readonly resume?: unknown }).resume, + ).toBe("function"); +}