asphodelos 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +593 -0
- package/dist/cli.d.mts +1 -0
- package/dist/cli.mjs +199 -0
- package/dist/core-DS3sAabb.mjs +4018 -0
- package/dist/format-B0W7HIAg.mjs +48 -0
- package/dist/index.d.mts +993 -0
- package/dist/index.mjs +202 -0
- package/dist/openapi-B4aAnx4P.mjs +40 -0
- package/dist/rolldown-runtime-wcPFST8Q.mjs +13 -0
- package/dist/shared-CrbXsFUJ.mjs +221 -0
- package/dist/vite-plugin/index.d.mts +11 -0
- package/dist/vite-plugin/index.mjs +336 -0
- package/package.json +71 -0
package/dist/cli.mjs
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
|
|
3
|
+
import * as NodeServices from "@effect/platform-node/NodeServices";
|
|
4
|
+
import { Console, Effect, FileSystem, Option, Schema, Stream } from "effect";
|
|
5
|
+
import path, { resolve } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { Argument, CliError, Command, Flag } from "effect/unstable/cli";
|
|
8
|
+
//#region src/cli/index.ts
|
|
9
|
+
const COMMAND_NAME = "asphodelos";
|
|
10
|
+
/** Config file `asphodelos` picks up from the working directory when `--config` is omitted. */
|
|
11
|
+
const DEFAULT_CONFIG_FILE = "asphodelos.config.ts";
|
|
12
|
+
/** Extensions a change has to carry to be worth regenerating for. */
|
|
13
|
+
const INPUT_EXTENSIONS = [
|
|
14
|
+
".yaml",
|
|
15
|
+
".json",
|
|
16
|
+
".tsp"
|
|
17
|
+
];
|
|
18
|
+
const DocumentPathSchema = Schema.String.pipe(Schema.refine(Schema.is(Schema.TemplateLiteral([Schema.String, Schema.Literals(INPUT_EXTENSIONS)])), { message: "an OpenAPI (.yaml, .json) or TypeSpec (.tsp) document" }));
|
|
19
|
+
const TypeScriptPathSchema = Schema.String.pipe(Schema.refine(Schema.is(Schema.TemplateLiteral([Schema.String, ".ts"])), { message: "a TypeScript file path ending in .ts" }));
|
|
20
|
+
/**
|
|
21
|
+
* The command line itself: what `asphodelos` accepts, what each piece means, and the schema every
|
|
22
|
+
* value is decoded through before {@link generate} ever sees it.
|
|
23
|
+
*/
|
|
24
|
+
const commandLine = {
|
|
25
|
+
input: Argument.file("input", { mustExist: true }).pipe(Argument.withSchema(DocumentPathSchema), Argument.withDescription("OpenAPI (.yaml, .json) or TypeSpec (.tsp) document to generate from"), Argument.withMetavar("input.{yaml,json,tsp}"), Argument.optional),
|
|
26
|
+
output: Flag.string("output").pipe(Flag.withAlias("o"), Flag.withSchema(TypeScriptPathSchema), Flag.withDescription("TypeScript file the generated app is written to"), Flag.withMetavar("output.ts"), Flag.optional),
|
|
27
|
+
config: Flag.file("config", { mustExist: true }).pipe(Flag.withAlias("c"), Flag.withDescription(`Config file to run (default: ./${DEFAULT_CONFIG_FILE})`), Flag.withMetavar("file"), Flag.optional),
|
|
28
|
+
watch: Flag.boolean("watch").pipe(Flag.withAlias("w"), Flag.withDescription("Rerun the config on every change to its documents or itself"), Flag.withDefault(false))
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* One pass over a config file: read it, parse the document it names, and run every generator it
|
|
32
|
+
* opts into.
|
|
33
|
+
*
|
|
34
|
+
* `reload` is for the passes after the first, where the config file may have been edited since it
|
|
35
|
+
* was imported.
|
|
36
|
+
*
|
|
37
|
+
* The generator pipeline pulls in the OpenAPI parser, the TypeSpec compiler and ts-morph.
|
|
38
|
+
* `--help`, `--version`, `--completions` and every rejected command line must not pay for that,
|
|
39
|
+
* so it is loaded here rather than at module scope. After the first pass the loader answers from
|
|
40
|
+
* cache, so a watch tick pays nothing.
|
|
41
|
+
*/
|
|
42
|
+
function runConfigPass(configPath, reload) {
|
|
43
|
+
return Effect.gen(function* () {
|
|
44
|
+
const [{ readConfig }, { parseOpenAPI }, { FormatOptions }, { cleanSplitOutputs, makeJob }] = yield* Effect.promise(() => Promise.all([
|
|
45
|
+
import("./index.mjs"),
|
|
46
|
+
import("./openapi-B4aAnx4P.mjs").then((n) => n.t),
|
|
47
|
+
import("./format-B0W7HIAg.mjs").then((n) => n.r),
|
|
48
|
+
import("./shared-CrbXsFUJ.mjs").then((n) => n.a)
|
|
49
|
+
]));
|
|
50
|
+
const config = yield* readConfig(configPath, reload);
|
|
51
|
+
const jobs = makeJob(yield* parseOpenAPI(config.input), config);
|
|
52
|
+
yield* cleanSplitOutputs(jobs);
|
|
53
|
+
return {
|
|
54
|
+
config,
|
|
55
|
+
report: (yield* Effect.all(jobs.map((job) => job.run(job.output)), { concurrency: "unbounded" }).pipe(Effect.provideService(FormatOptions, config.format ?? {}))).filter((message) => message !== "").join("\n")
|
|
56
|
+
};
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* A pass whose failure is printed rather than raised, so the watch loop survives it, and which
|
|
61
|
+
* answers with the input directory the config now names.
|
|
62
|
+
*
|
|
63
|
+
* `undefined` means the pass did not get far enough to say — the config is missing, will not
|
|
64
|
+
* import, or does not validate. The loop keeps watching the config either way.
|
|
65
|
+
*/
|
|
66
|
+
function reportConfigPass(configPath, reload) {
|
|
67
|
+
return Effect.gen(function* () {
|
|
68
|
+
const result = yield* Effect.result(runConfigPass(configPath, reload));
|
|
69
|
+
if (result._tag === "Failure") {
|
|
70
|
+
yield* Console.error(`❌ ${result.failure.message}`);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
yield* Console.log(result.success.report);
|
|
74
|
+
return path.dirname(resolve(process.cwd(), result.success.config.input));
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Waits for the first change worth regenerating for.
|
|
79
|
+
*
|
|
80
|
+
* `runHead` is what ends the wait: it takes the first change that survives the filter and the
|
|
81
|
+
* debounce, then tears the stream down, so the next round can watch wherever the reloaded config
|
|
82
|
+
* points. Changes are debounced because one save is several filesystem events — an editor writes,
|
|
83
|
+
* renames and touches — and a round per event would race itself.
|
|
84
|
+
*
|
|
85
|
+
* `WatchEvent.path` is relative to the directory it came from, which is why each stream is
|
|
86
|
+
* filtered after being resolved back to an absolute path.
|
|
87
|
+
*/
|
|
88
|
+
function awaitChange(configPath, inputDirectory) {
|
|
89
|
+
return Effect.gen(function* () {
|
|
90
|
+
const fs = yield* FileSystem.FileSystem;
|
|
91
|
+
const stream = [path.dirname(configPath), ...inputDirectory === void 0 ? [] : [inputDirectory]].filter((directory, index, all) => all.indexOf(directory) === index).map((directory) => Stream.map(fs.watch(directory), (event) => path.resolve(directory, event.path))).reduce((merged, next) => Stream.merge(merged, next));
|
|
92
|
+
yield* Stream.runHead(Stream.debounce(Stream.filter(stream, (changed) => changed === configPath || INPUT_EXTENSIONS.some((extension) => changed.endsWith(extension))), "200 millis"));
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Regenerates on every change to the input documents or the config, until interrupted.
|
|
97
|
+
*
|
|
98
|
+
* Two things can invalidate the output, so both are watched: the config file, and the directory
|
|
99
|
+
* the document it names lives in — a TypeSpec entry imports its siblings and a `$ref` can point
|
|
100
|
+
* at one, so the file named by `input` is rarely the only one that matters. The config file is
|
|
101
|
+
* watched through its directory rather than directly, so an editor that saves by renaming does
|
|
102
|
+
* not take the watcher down with it.
|
|
103
|
+
*
|
|
104
|
+
* Recursive rather than a loop because the directory to watch comes from the config, and the
|
|
105
|
+
* config is re-read every round: an edit that repoints `input` elsewhere has to move the watcher
|
|
106
|
+
* with it, which means new streams rather than new values.
|
|
107
|
+
*/
|
|
108
|
+
function watchConfig(configPath, inputDirectory) {
|
|
109
|
+
return Effect.gen(function* () {
|
|
110
|
+
yield* Console.log(inputDirectory === void 0 ? `\n👀 Watching ${configPath} — Ctrl-C to stop` : `\n👀 Watching ${inputDirectory} and ${configPath} — Ctrl-C to stop`);
|
|
111
|
+
yield* awaitChange(configPath, inputDirectory);
|
|
112
|
+
return yield* watchConfig(configPath, yield* reportConfigPass(configPath, true));
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Everything the command does once the command line has parsed.
|
|
117
|
+
*
|
|
118
|
+
* It resolves to one of two modes and nothing else: an `<input>` with an `-o` writes a single app
|
|
119
|
+
* from a single document, and anything else runs a config file — which is what opts in the
|
|
120
|
+
* components, eden, types, test, mock and client-hook generators. `--config` and `<input>` are
|
|
121
|
+
* mutually exclusive, and each of `<input>` / `--output` is meaningless without the other.
|
|
122
|
+
*
|
|
123
|
+
* Everything past the guard clauses fails with something carrying a `message`, so the single
|
|
124
|
+
* `mapError` at the end is where all of it turns into rendered CLI output.
|
|
125
|
+
*/
|
|
126
|
+
function generate(args) {
|
|
127
|
+
return Effect.gen(function* () {
|
|
128
|
+
const input = Option.getOrUndefined(args.input);
|
|
129
|
+
const output = Option.getOrUndefined(args.output);
|
|
130
|
+
const configPath = Option.getOrUndefined(args.config);
|
|
131
|
+
const reject = (message) => new CliError.ShowHelp({
|
|
132
|
+
commandPath: [COMMAND_NAME],
|
|
133
|
+
errors: [new CliError.UserError({
|
|
134
|
+
cause: new Error(message),
|
|
135
|
+
userMessage: message
|
|
136
|
+
})]
|
|
137
|
+
});
|
|
138
|
+
if (configPath !== void 0 && (input !== void 0 || output !== void 0)) return yield* reject("--config cannot be combined with <input> or --output. A config file already names its own input and outputs.");
|
|
139
|
+
if (input !== void 0 && output === void 0) return yield* reject("<input> requires -o <output.ts>.");
|
|
140
|
+
if (output !== void 0 && input === void 0) return yield* reject("-o <output.ts> requires an <input> document.");
|
|
141
|
+
if (args.watch && (input !== void 0 || output !== void 0)) return yield* reject("--watch runs a config file, so it cannot be combined with <input> or --output.");
|
|
142
|
+
if (input !== void 0 && output !== void 0) {
|
|
143
|
+
const [{ parseOpenAPI }, { elysia }] = yield* Effect.promise(() => Promise.all([import("./openapi-B4aAnx4P.mjs").then((n) => n.t), import("./core-DS3sAabb.mjs").then((n) => n.t)]));
|
|
144
|
+
return yield* Console.log(yield* elysia(yield* parseOpenAPI(input), { output }));
|
|
145
|
+
}
|
|
146
|
+
const resolvedConfig = configPath ?? DEFAULT_CONFIG_FILE;
|
|
147
|
+
if (args.watch) return yield* watchConfig(resolve(process.cwd(), resolvedConfig), yield* reportConfigPass(resolvedConfig, false));
|
|
148
|
+
const first = yield* runConfigPass(resolvedConfig, false).pipe(Effect.mapError((error) => configPath === void 0 && error._tag === "ConfigError" && error.notFound === true ? reject(error.message) : error));
|
|
149
|
+
yield* Console.log(first.report);
|
|
150
|
+
}).pipe(Effect.mapError((error) => CliError.isCliError(error) ? error : new CliError.UserError({
|
|
151
|
+
cause: error,
|
|
152
|
+
userMessage: error.message
|
|
153
|
+
})));
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* The `asphodelos` command: parsing, validation, `--help`, `--version` and shell completions are
|
|
157
|
+
* owned by `effect/unstable/cli`, {@link generate} is the rest.
|
|
158
|
+
*/
|
|
159
|
+
const cli = Command.make(COMMAND_NAME, commandLine, generate).pipe(Command.withDescription("Generate Elysia code from OpenAPI or TypeSpec"), Command.withExamples([
|
|
160
|
+
{
|
|
161
|
+
command: "asphodelos openapi.yaml -o src/index.ts",
|
|
162
|
+
description: "Generate a single app from one document"
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
command: "asphodelos",
|
|
166
|
+
description: `Run every generator declared in ./${DEFAULT_CONFIG_FILE}`
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
command: "asphodelos --config config/api.config.ts",
|
|
170
|
+
description: "Run a config file from another location"
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
command: "asphodelos --watch",
|
|
174
|
+
description: "Rerun on every change to the input documents or the config"
|
|
175
|
+
}
|
|
176
|
+
]));
|
|
177
|
+
/**
|
|
178
|
+
* Runs `asphodelos` against an argument list.
|
|
179
|
+
*
|
|
180
|
+
* `entryUrl` is the `import.meta.url` of the executable, and `--version` is read from the
|
|
181
|
+
* `package.json` next to it rather than baked in, so the two can never disagree.
|
|
182
|
+
*/
|
|
183
|
+
function asphodelos(argv, entryUrl) {
|
|
184
|
+
return Effect.gen(function* () {
|
|
185
|
+
const manifestPath = fileURLToPath(new URL("../package.json", entryUrl));
|
|
186
|
+
const source = yield* (yield* FileSystem.FileSystem).readFileString(manifestPath);
|
|
187
|
+
const manifest = yield* Effect.try({
|
|
188
|
+
try: () => JSON.parse(source),
|
|
189
|
+
catch: (cause) => new Error(`${manifestPath} is not valid JSON`, { cause })
|
|
190
|
+
});
|
|
191
|
+
const { version } = yield* Schema.decodeUnknownEffect(Schema.Struct({ version: Schema.String }))(manifest);
|
|
192
|
+
return yield* Command.runWith(cli, { version })(argv);
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
//#endregion
|
|
196
|
+
//#region src/index.ts
|
|
197
|
+
NodeRuntime.runMain(asphodelos(process.argv.slice(2), import.meta.url).pipe(Effect.provide(NodeServices.layer)));
|
|
198
|
+
//#endregion
|
|
199
|
+
export {};
|