|
1 | 1 | # Configliere |
2 | 2 |
|
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.** |
6 | 4 |
|
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. |
11 | 8 |
|
12 | | -The architecture has four small pieces: |
| 9 | +Configliere matches input to an intent: a method at an application-relative |
| 10 | +route, such as: |
13 | 11 |
|
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 | +``` |
22 | 30 |
|
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 |
26 | 32 |
|
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. |
28 | 36 |
|
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: |
31 | 38 |
|
32 | 39 | ```ts |
33 | | -import process from "node:process"; |
34 | 40 | import { |
35 | 41 | command, |
36 | 42 | description, |
37 | 43 | name, |
38 | 44 | option, |
39 | | - parse, |
40 | | - printHelp, |
41 | | - printVersion, |
| 45 | + route, |
| 46 | + routes, |
42 | 47 | schema, |
43 | 48 | toggle, |
44 | 49 | version, |
45 | 50 | } from "@frontside/configliere"; |
46 | | -import { type } from "arktype"; |
| 51 | +import * as z from "zod"; |
47 | 52 |
|
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."), |
51 | 56 | 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 | + ), |
60 | 72 | ), |
61 | 73 | ); |
| 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"; |
62 | 102 |
|
63 | 103 | const result = parse(app, { argv: process.argv.slice(2) }); |
64 | 104 |
|
65 | 105 | if (!result.ok) { |
66 | | - console.error(result.code, result); |
| 106 | + console.error(printErrors(result)); |
67 | 107 | process.exit(1); |
68 | 108 | } |
69 | 109 |
|
70 | 110 | switch (result.method) { |
71 | 111 | case "help": |
72 | 112 | console.log(printHelp(result)); |
73 | 113 | break; |
| 114 | + |
74 | 115 | case "version": |
75 | 116 | console.log(printVersion(result)); |
76 | 117 | break; |
| 118 | + |
77 | 119 | 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 | + } |
81 | 136 | } |
| 137 | +``` |
82 | 138 |
|
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 | +}); |
86 | 175 | ``` |
87 | 176 |
|
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. |
91 | 182 |
|
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: |
99 | 184 |
|
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 | +``` |
104 | 188 |
|
105 | | -## Add commands, keep dispatch flat |
| 189 | +## Pause without surrendering the type system |
106 | 190 |
|
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: |
110 | 194 |
|
111 | 195 | ```ts |
112 | 196 | import process from "node:process"; |
113 | 197 | import { |
| 198 | + checkpoint, |
114 | 199 | command, |
115 | 200 | name, |
116 | 201 | option, |
117 | 202 | parse, |
118 | | - printHelp, |
119 | | - routes, |
| 203 | + type Result, |
120 | 204 | schema, |
121 | | - toggle, |
| 205 | + type ValueSource, |
122 | 206 | } from "@frontside/configliere"; |
123 | | -import { type } from "arktype"; |
| 207 | +import * as z from "zod"; |
124 | 208 |
|
125 | 209 | 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())), |
138 | 214 | ); |
139 | 215 |
|
140 | | -const result = parse(app, { argv: process.argv.slice(2) }); |
| 216 | +const step = parse(app, { argv: process.argv.slice(2) }); |
141 | 217 |
|
142 | | -if (!result.ok) { |
143 | | - console.error(result.code, result); |
| 218 | +if (!step.ok) { |
144 | 219 | process.exit(1); |
145 | 220 | } |
146 | 221 |
|
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 |
177 | 223 |
|
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); |
181 | 226 |
|
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 } |
187 | 229 | } |
188 | 230 |
|
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[]>>; |
195 | 232 | ``` |
196 | 233 |
|
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