Skip to content

Commit b5af3af

Browse files
committed
✨ Bind environment sources
1 parent 042cfa5 commit b5af3af

17 files changed

Lines changed: 1121 additions & 174 deletions

‎README.md‎

Lines changed: 171 additions & 142 deletions
Original file line numberDiff line numberDiff line change
@@ -1,212 +1,241 @@
11
# Configliere
22

3-
> [!NOTE]
4-
> Configliere is undergoing a ground-up rebuild. The ideas below are working,
5-
> but the API is not yet stable.
3+
**A statically typed entry-point router for command-line applications.**
64

7-
Configliere models a command-line program as a tree of typed routes. Parsing
8-
does not merely produce a bag of options: it resolves an intent such as
9-
`HELP /`, `VERSION /`, or `EXECUTE /serve` and, for execution, binds a model for
10-
every route that led there.
5+
An argument parser tells you what the user typed. Configliere tells you where
6+
and how they intend to enter your program. For execution, it marshals a
7+
validated, statically typed model for that entry point.
118

12-
The architecture has four small pieces:
9+
Configliere matches input to an intent: a method at an application-relative
10+
route, such as:
1311

14-
- A **route** is an application-relative address such as `/` or `/serve`.
15-
- A **method** says what to do with that route. Every route supports help;
16-
`version()` adds version and `command()` creates an executable route.
17-
- Each route owns its own **model**. An execute intent contains the current
18-
`model` and a path-addressed `models` map for the entire matched lineage.
19-
- Definitions are immutable composition pipelines. Elements such as
20-
`description()`, `option()`, `toggle()`, `version()`, and `routes()` enrich
21-
both the runtime definition and its TypeScript type.
12+
```text
13+
HELP /
14+
VERSION /
15+
EXECUTE /serve
16+
HELP /database/clean
17+
EXECUTE /database/clean
18+
```
19+
20+
Every reachable intent appears in the result type. Narrow `method` and `route`,
21+
and TypeScript knows the exact model available at that entry point.
22+
23+
Configliere is not a CLI framework. It does not own handlers, effects, output,
24+
or process lifetime. It is not a CLI parser whose product is a bag of flags. Its
25+
product is a typed intent; your application decides what that intent does.
26+
27+
```text
28+
input → route → method → required binding and validation → intent
29+
```
2230

23-
Parameters are validated with [Standard Schema](https://standardschema.dev/), so
24-
Configliere works with ArkType, Zod, Valibot, and other conforming schema
25-
libraries. The examples below use ArkType.
31+
## Define every way into the program
2632

27-
## One command, one model
33+
`route()` declares an address. `command()` declares an executable route. Every
34+
route supports help; version and execution exist only where they are explicitly
35+
added.
2836

29-
`command()` is an executable route. Here it creates the root route `/` with
30-
help, version, and execute methods:
37+
Definitions are immutable composition pipelines, not handler registrations:
3138

3239
```ts
33-
import process from "node:process";
3440
import {
3541
command,
3642
description,
3743
name,
3844
option,
39-
parse,
40-
printHelp,
41-
printVersion,
45+
route,
46+
routes,
4247
schema,
4348
toggle,
4449
version,
4550
} from "@frontside/configliere";
46-
import { type } from "arktype";
51+
import * as z from "zod";
4752

48-
const app = command(
49-
name("server"),
50-
description("Run the HTTP server."),
53+
export const app = command(
54+
name("simulacrum"),
55+
description("Run and manage local service simulators."),
5156
version("1.0.0"),
52-
option(
53-
name("port"),
54-
description("Port to listen on."),
55-
schema(type("number")),
56-
),
57-
toggle(
58-
name("verbose"),
59-
description("Print request diagnostics."),
57+
toggle(name("verbose")),
58+
routes(
59+
command(
60+
name("serve"),
61+
option(name("port"), schema(z.number().default(4000))),
62+
),
63+
route(
64+
name("database"),
65+
routes(
66+
command(
67+
name("clean"),
68+
toggle(name("dryRun")),
69+
),
70+
),
71+
),
6072
),
6173
);
74+
```
75+
76+
That definition makes these entry points—and no others—reachable:
77+
78+
```text
79+
HELP / VERSION / EXECUTE /
80+
HELP /serve EXECUTE /serve
81+
HELP /database
82+
HELP /database/clean EXECUTE /database/clean
83+
```
84+
85+
The root name identifies the executable; it is not repeated in route IDs.
86+
`simulacrum serve` therefore selects `/serve`, not `/simulacrum/serve`.
87+
88+
## Route an intent
89+
90+
`parse()` returns a discriminated union of the reachable entry points. Dispatch
91+
can stay flat even when the route tree is deep:
92+
93+
```ts
94+
import process from "node:process";
95+
import {
96+
parse,
97+
printErrors,
98+
printHelp,
99+
printVersion,
100+
} from "@frontside/configliere";
101+
import { app } from "./app.ts";
62102

63103
const result = parse(app, { argv: process.argv.slice(2) });
64104

65105
if (!result.ok) {
66-
console.error(result.code, result);
106+
console.error(printErrors(result));
67107
process.exit(1);
68108
}
69109

70110
switch (result.method) {
71111
case "help":
72112
console.log(printHelp(result));
73113
break;
114+
74115
case "version":
75116
console.log(printVersion(result));
76117
break;
118+
77119
case "execute":
78-
// Exactly { port: number; verbose: boolean }, not Record<string, unknown>.
79-
serve(result.model);
80-
break;
120+
switch (result.route) {
121+
case "/":
122+
result.model.verbose; // boolean
123+
break;
124+
125+
case "/serve":
126+
result.model.port; // number
127+
result.models["/"].verbose; // boolean
128+
break;
129+
130+
case "/database/clean":
131+
result.model.dryRun; // boolean
132+
result.models["/"]; // { verbose: boolean }
133+
result.models["/database"]; // {}
134+
break;
135+
}
81136
}
137+
```
82138

83-
function serve(model: { port: number; verbose: boolean }): void {
84-
console.log(`listening on ${model.port}; verbose=${model.verbose}`);
85-
}
139+
An execute intent has two views of configuration:
140+
141+
- `model` is owned by the selected route.
142+
- `models` contains the statically typed model for every route along the
143+
selected path. Sibling routes are absent from both the value and its type.
144+
145+
| Invocation | Intent and model |
146+
| ------------------------------------- | ------------------------------------------------------------------- |
147+
| `simulacrum --verbose` | `EXECUTE /` with `{ verbose: true }` |
148+
| `simulacrum serve --port 4100` | `EXECUTE /serve` with `{ port: 4100 }` |
149+
| `simulacrum database clean --dry-run` | `EXECUTE /database/clean` with `{ dryRun: true }` |
150+
| `simulacrum --help database clean` | `HELP /database/clean`; controls target the deepest selected route |
151+
| `simulacrum database` | `method-not-allowed`; `/database` does not support execution |
152+
| `simulacrum serve --port nope` | `unprocessable-content`; invalid data never reaches the application |
153+
154+
Command literals are routing tokens, not positional arguments. Configliere
155+
selects the route first, then binds options to the route segment that owns them.
156+
This makes identical option names on parent and child routes unambiguous.
157+
158+
## Marshal configuration into the route
159+
160+
CLI arguments are only one source. Configliere can marshal JavaScript values and
161+
flat environment records into the same route-local models:
162+
163+
```ts
164+
const result = parse(app, {
165+
argv: process.argv.slice(2),
166+
values: [{
167+
name: "config.json",
168+
value: { serve: { port: 4200 } },
169+
}],
170+
envs: [{
171+
name: "process",
172+
value: process.env,
173+
}],
174+
});
86175
```
87176

88-
`parse()` receives the arguments _after_ the executable name, which is why the
89-
example passes `process.argv.slice(2)`. The full command lines above resolve as
90-
follows:
177+
For `/serve`, `serve.port` and `SERVE_PORT` both address its `port` parameter.
178+
CLI text and environment text are decoded; JavaScript values are used directly.
179+
The resulting value is then validated by its
180+
[Standard Schema](https://standardschema.dev/) schema. Zod, ArkType, Valibot,
181+
and other conforming libraries work without adapters.
91182

92-
| Invocation | Result |
93-
| ------------------------------ | ---------------------------------------------------------------------------- |
94-
| `server --help` | `HELP /`; `printHelp()` renders the route's usage, options, and descriptions |
95-
| `server --version` | `VERSION /`; `printVersion()` returns `server 1.0.0` |
96-
| `server --port 4000` | `EXECUTE /`; `model` is `{ port: 4000, verbose: false }` |
97-
| `server --port=4000 --verbose` | `EXECUTE /`; `model` is `{ port: 4000, verbose: true }` |
98-
| `server --port nope` | `unprocessable-content`; execution never receives an invalid model |
183+
Source precedence is explicit:
99184

100-
The method is a discriminant. Once the switch reaches `"execute"`, TypeScript
101-
knows that `model` exists and has exactly the shape assembled by the command's
102-
parameters. Help and version are resolved before execution validation, so
103-
`server --help` does not require a port.
185+
```text
186+
CLI → environment → JavaScript values → schema default
187+
```
104188

105-
## Add commands, keep dispatch flat
189+
## Pause without surrendering the type system
106190

107-
Routes form a tree, but callers do not have to mirror that tree with nested
108-
conditionals. `IntentsOf<typeof app>` is a flat union of every method and route
109-
the definition can reach, so `result.route` is another useful discriminant.
191+
Sometimes the route cannot be fully configured—or even fully discovered—until
192+
the application performs I/O. Configliere can pause at a typed checkpoint and
193+
resume with the result:
110194

111195
```ts
112196
import process from "node:process";
113197
import {
198+
checkpoint,
114199
command,
115200
name,
116201
option,
117202
parse,
118-
printHelp,
119-
routes,
203+
type Result,
120204
schema,
121-
toggle,
205+
type ValueSource,
122206
} from "@frontside/configliere";
123-
import { type } from "arktype";
207+
import * as z from "zod";
124208

125209
const app = command(
126-
name("simulacrum"),
127-
toggle(name("verbose")),
128-
routes(
129-
command(
130-
name("serve"),
131-
option(name("port"), schema(type("number"))),
132-
),
133-
command(
134-
name("inspect"),
135-
toggle(name("json")),
136-
),
137-
),
210+
name("server"),
211+
option(name("config"), schema(z.string())),
212+
checkpoint(),
213+
option(name("port"), schema(z.number())),
138214
);
139215

140-
const result = parse(app, { argv: process.argv.slice(2) });
216+
const step = parse(app, { argv: process.argv.slice(2) });
141217

142-
if (!result.ok) {
143-
console.error(result.code, result);
218+
if (!step.ok) {
144219
process.exit(1);
145220
}
146221

147-
if (result.method === "help") {
148-
console.log(printHelp(result));
149-
} else {
150-
switch (result.route) {
151-
case "/":
152-
// model: { verbose: boolean }
153-
// models: { "/": { verbose: boolean } }
154-
start(result.model);
155-
break;
156-
157-
case "/serve":
158-
// model is only the selected route's model.
159-
result.model.port; // number
160-
161-
// models contains every model on the matched route lineage.
162-
result.models["/"].verbose; // boolean
163-
result.models["/serve"].port; // number
164-
165-
// @ts-expect-error: /inspect was not part of this match.
166-
result.models["/inspect"];
167-
serve(result.model, result.models["/"]);
168-
break;
169-
170-
case "/inspect":
171-
// model: { json: boolean }
172-
// models: { "/": { verbose: boolean }, "/inspect": { json: boolean } }
173-
inspect(result.model, result.models["/"]);
174-
break;
175-
}
176-
}
222+
step.model.config; // string—the model resolved before the checkpoint
177223

178-
function start(model: { verbose: boolean }): void {
179-
console.log(`simulacrum; verbose=${model.verbose}`);
180-
}
224+
const loaded = await load(step.model.config);
225+
const result = step.resume(loaded);
181226

182-
function serve(
183-
model: { port: number },
184-
root: { verbose: boolean },
185-
): void {
186-
console.log(`serving on ${model.port}; verbose=${root.verbose}`);
227+
if (result.ok && result.method === "execute") {
228+
result.model; // { config: string; port: number }
187229
}
188230

189-
function inspect(
190-
model: { json: boolean },
191-
root: { verbose: boolean },
192-
): void {
193-
console.log(`inspecting; json=${model.json}; verbose=${root.verbose}`);
194-
}
231+
declare function load(path: string): Promise<Result<ValueSource[]>>;
195232
```
196233

197-
| Invocation | Result |
198-
| ---------------------------------------- | ---------------------------------------------------------------------------------------------------- |
199-
| `simulacrum --verbose` | `EXECUTE /`; `model` and `models["/"]` are `{ verbose: true }` |
200-
| `simulacrum --verbose serve --port 4000` | `EXECUTE /serve`; `model` is `{ port: 4000 }`, while `models` also contains `"/": { verbose: true }` |
201-
| `simulacrum inspect --json` | `EXECUTE /inspect`; `model` is `{ json: true }` and the root model has `{ verbose: false }` |
202-
| `simulacrum serve --help` | `HELP /serve`; no root or command model is validated |
203-
204-
Route tokens select the deepest matching route; they are not positional
205-
arguments. Options on each segment bind only to the model owned by that route.
206-
The root definition's name identifies the executable but is not repeated in
207-
route IDs: the root is `/`, not `/simulacrum`.
208-
209-
For a deeper route such as `/database/clean`, the same rule applies:
210-
`result.model` is the clean command's model, while `result.models` has exact
211-
entries for `/`, `/database`, and `/database/clean`. Sibling models are absent
212-
at runtime and from the TypeScript type.
234+
The parser remains synchronous and performs no I/O. The caller loads the file
235+
and resumes with a `Result`; loader failures enter the ordinary issue path.
236+
Unconsumed CLI input survives the pause, so a later `--port 5000` can override
237+
the value loaded from the file.
238+
239+
The same phase mechanism can add options or routes from runtime data. Parsing
240+
then continues against the expanded route graph, and the continuation type
241+
describes the entry points that can appear next.

0 commit comments

Comments
 (0)