@shipworthy/openapi-cli 0.0.1 → 1.0.0
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/README.md +170 -106
- package/dist/bin.js +2 -1
- package/dist/cli.d.ts +1 -1
- package/dist/commands/generate/command.d.ts +19 -2
- package/dist/commands/generate/command.js +74 -6
- package/dist/commands/generate/{plugins/typescript/flags.d.ts → flags.d.ts} +5 -4
- package/dist/commands/generate/{plugins/typescript/flags.js → flags.js} +5 -12
- package/dist/commands/generate/output.d.ts +15 -0
- package/dist/commands/generate/output.js +35 -0
- package/dist/commands/generate/plan.d.ts +53 -0
- package/dist/commands/generate/plan.js +93 -0
- package/dist/commands/generate/render.d.ts +25 -0
- package/dist/commands/generate/render.js +63 -0
- package/dist/commands/generate/source.d.ts +5 -0
- package/dist/commands/generate/source.js +18 -0
- package/dist/commands/generate/stamp.d.ts +14 -10
- package/dist/commands/generate/stamp.js +16 -28
- package/dist/commands/input.js +2 -2
- package/dist/commands/overlay/command.js +4 -3
- package/package.json +18 -17
- package/dist/commands/generate/ensure.d.ts +0 -9
- package/dist/commands/generate/ensure.js +0 -31
- package/dist/commands/generate/files.d.ts +0 -4
- package/dist/commands/generate/files.js +0 -16
- package/dist/commands/generate/force.d.ts +0 -2
- package/dist/commands/generate/force.js +0 -2
- package/dist/commands/generate/plugins/standard-schema/plugin.d.ts +0 -7
- package/dist/commands/generate/plugins/standard-schema/plugin.js +0 -44
- package/dist/commands/generate/plugins/tanstack-query/plugin.d.ts +0 -9
- package/dist/commands/generate/plugins/tanstack-query/plugin.js +0 -42
- package/dist/commands/generate/plugins/typescript/plugin.d.ts +0 -32
- package/dist/commands/generate/plugins/typescript/plugin.js +0 -84
- package/dist/commands/generate/plugins/zod/plugin.d.ts +0 -8
- package/dist/commands/generate/plugins/zod/plugin.js +0 -48
- package/dist/commands/generate/publish.d.ts +0 -11
- package/dist/commands/generate/publish.js +0 -23
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Flag } from "effect/cli";
|
|
2
2
|
export interface TypeFlags {
|
|
3
3
|
readonly arrayLength: boolean;
|
|
4
|
+
readonly camelCase: boolean;
|
|
4
5
|
readonly conditionalEnums: boolean;
|
|
5
6
|
readonly dedupeEnums: boolean;
|
|
6
7
|
readonly enum: boolean;
|
|
@@ -9,8 +10,8 @@ export interface TypeFlags {
|
|
|
9
10
|
readonly readWriteMarkers: boolean;
|
|
10
11
|
readonly rootTypes: boolean;
|
|
11
12
|
}
|
|
12
|
-
export declare const defaultTypeFlags: TypeFlags;
|
|
13
13
|
export declare const typeFlags: {
|
|
14
|
+
camelCase: Flag.Flag<boolean>;
|
|
14
15
|
enum: Flag.Flag<boolean>;
|
|
15
16
|
enumValues: Flag.Flag<boolean>;
|
|
16
17
|
conditionalEnums: Flag.Flag<boolean>;
|
|
@@ -20,10 +21,10 @@ export declare const typeFlags: {
|
|
|
20
21
|
rootTypes: Flag.Flag<boolean>;
|
|
21
22
|
readWriteMarkers: Flag.Flag<boolean>;
|
|
22
23
|
};
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
}): {
|
|
24
|
+
/** Options for the `typescript` plugin. */
|
|
25
|
+
export declare function pluginOptions(flags: TypeFlags, silent: boolean): {
|
|
26
26
|
arrayLength: boolean;
|
|
27
|
+
camelCase: boolean;
|
|
27
28
|
conditionalEnums: boolean;
|
|
28
29
|
dedupeEnums: boolean;
|
|
29
30
|
enum: boolean;
|
|
@@ -2,17 +2,8 @@ import { Flag } from "effect/cli";
|
|
|
2
2
|
function flag(name, description) {
|
|
3
3
|
return Flag.Boolean(name).pipe(Flag.withDescription(description), Flag.withDefault(false));
|
|
4
4
|
}
|
|
5
|
-
export const defaultTypeFlags = {
|
|
6
|
-
arrayLength: true,
|
|
7
|
-
conditionalEnums: false,
|
|
8
|
-
dedupeEnums: false,
|
|
9
|
-
enum: false,
|
|
10
|
-
enumValues: false,
|
|
11
|
-
exportType: false,
|
|
12
|
-
readWriteMarkers: false,
|
|
13
|
-
rootTypes: false,
|
|
14
|
-
};
|
|
15
5
|
export const typeFlags = {
|
|
6
|
+
camelCase: flag("camel-case", "Use camelCase for snake_case schema fields and parameters in every generated file."),
|
|
16
7
|
enum: flag("enum", "Export true TS enums instead of unions."),
|
|
17
8
|
enumValues: flag("enum-values", "Export enum values as arrays."),
|
|
18
9
|
conditionalEnums: flag("conditional-enums", "Only generate true TS enums when enum metadata is available."),
|
|
@@ -22,9 +13,11 @@ export const typeFlags = {
|
|
|
22
13
|
rootTypes: flag("root-types", "Export component and operation types at the root level."),
|
|
23
14
|
readWriteMarkers: flag("read-write-markers", "Generate $Read/$Write markers for readOnly/writeOnly properties."),
|
|
24
15
|
};
|
|
25
|
-
|
|
16
|
+
/** Options for the `typescript` plugin. */
|
|
17
|
+
export function pluginOptions(flags, silent) {
|
|
26
18
|
return {
|
|
27
19
|
arrayLength: flags.arrayLength,
|
|
20
|
+
camelCase: flags.camelCase,
|
|
28
21
|
conditionalEnums: flags.conditionalEnums,
|
|
29
22
|
dedupeEnums: flags.dedupeEnums,
|
|
30
23
|
enum: flags.enum,
|
|
@@ -32,6 +25,6 @@ export function pluginOptions(flags) {
|
|
|
32
25
|
exportType: flags.exportType,
|
|
33
26
|
readWriteMarkers: flags.readWriteMarkers,
|
|
34
27
|
rootTypes: flags.rootTypes,
|
|
35
|
-
silent
|
|
28
|
+
silent,
|
|
36
29
|
};
|
|
37
30
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Effect, FileSystem, Path, Stdio } from "effect";
|
|
2
|
+
import { CliError } from "effect/cli";
|
|
3
|
+
import type { RenderedFile } from "./render.ts";
|
|
4
|
+
export declare const writeStdout: (text: string) => Effect.Effect<void, import("effect/PlatformError").PlatformError, Stdio.Stdio>;
|
|
5
|
+
/** Writes every file into `directory` concurrently, then logs one line per file. */
|
|
6
|
+
export declare const writeFiles: (files: readonly RenderedFile[], options: {
|
|
7
|
+
readonly directory: string;
|
|
8
|
+
/** Input URL href, or `stdin`. */
|
|
9
|
+
readonly label: string;
|
|
10
|
+
/** `--output` as typed. */
|
|
11
|
+
readonly outputLabel: string;
|
|
12
|
+
readonly started: number;
|
|
13
|
+
}) => Effect.Effect<void, import("effect/PlatformError").PlatformError, FileSystem.FileSystem | Path.Path>;
|
|
14
|
+
/** Fails unless every file in `directory` matches its generated contents. */
|
|
15
|
+
export declare const checkFiles: (files: readonly RenderedFile[], directory: string) => Effect.Effect<undefined, CliError.UserError, FileSystem.FileSystem | Path.Path>;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { Array, Clock, Console, Effect, FileSystem, Option, Path, pipe, Stdio, Stream, String, } from "effect";
|
|
2
|
+
import { CliError } from "effect/cli";
|
|
3
|
+
function formatTime(t) {
|
|
4
|
+
if (t < 1000)
|
|
5
|
+
return `${Math.round(10 * t) / 10}ms`;
|
|
6
|
+
if (t < 60000)
|
|
7
|
+
return `${Math.round(t / 100) / 10}s`;
|
|
8
|
+
return `${Math.round(t / 6000) / 10}m`;
|
|
9
|
+
}
|
|
10
|
+
export const writeStdout = Effect.fn("writeStdout")(function* (text) {
|
|
11
|
+
const stdio = yield* Stdio.Stdio;
|
|
12
|
+
yield* Stream.run(Stream.make(text), stdio.stdout({ endOnDone: false }));
|
|
13
|
+
});
|
|
14
|
+
/** Writes every file into `directory` concurrently, then logs one line per file. */
|
|
15
|
+
export const writeFiles = Effect.fn("writeFiles")(function* (files, options) {
|
|
16
|
+
const fs = yield* FileSystem.FileSystem;
|
|
17
|
+
const path = yield* Path.Path;
|
|
18
|
+
yield* fs.makeDirectory(options.directory, { recursive: true });
|
|
19
|
+
yield* Effect.forEach(files, (file) => fs.writeFileString(path.join(options.directory, file.name), file.contents), { concurrency: "unbounded", discard: true });
|
|
20
|
+
const elapsed = formatTime((yield* Clock.currentTimeMillis) - options.started);
|
|
21
|
+
const output = pipe(options.outputLabel, String.replace(/(?<=.)\/+$/, ""));
|
|
22
|
+
yield* Effect.forEach(files, (file) => Console.log(`${options.label} → ${output}/${file.name} [${elapsed}]`), { discard: true });
|
|
23
|
+
});
|
|
24
|
+
/** Fails unless every file in `directory` matches its generated contents. */
|
|
25
|
+
export const checkFiles = Effect.fn("checkFiles")(function* (files, directory) {
|
|
26
|
+
const fs = yield* FileSystem.FileSystem;
|
|
27
|
+
const path = yield* Path.Path;
|
|
28
|
+
const stale = yield* Effect.forEach(files, (file) => fs.readFileString(path.join(directory, file.name)).pipe(Effect.option, Effect.map((existing) => Option.contains(existing, file.contents) ? Option.none() : Option.some(file.name))), { concurrency: "unbounded" });
|
|
29
|
+
const names = Array.getSomes(stale);
|
|
30
|
+
if (names.length === 0)
|
|
31
|
+
return;
|
|
32
|
+
return yield* new CliError.UserError({
|
|
33
|
+
cause: `Generated files are not up-to-date: ${Array.join(names, ", ")}`,
|
|
34
|
+
});
|
|
35
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { Option, Result, Schema } from "effect";
|
|
2
|
+
declare const pluginNames: readonly ["standard-schema", "tanstack-query", "typescript", "zod"];
|
|
3
|
+
type PluginName = (typeof pluginNames)[number];
|
|
4
|
+
export declare const ClientName: Schema.String;
|
|
5
|
+
interface Generator {
|
|
6
|
+
readonly specifier: string;
|
|
7
|
+
readonly exportName: string;
|
|
8
|
+
}
|
|
9
|
+
interface Plugin {
|
|
10
|
+
readonly generator: Generator;
|
|
11
|
+
/** Whether the plugin's file imports the shared `types.ts`. */
|
|
12
|
+
readonly types: boolean;
|
|
13
|
+
}
|
|
14
|
+
export declare const plugins: Readonly<Record<PluginName, Plugin>>;
|
|
15
|
+
/** One file a run writes, with the plugin that renders it. */
|
|
16
|
+
export type OutputFile = {
|
|
17
|
+
readonly plugin: "typescript";
|
|
18
|
+
readonly name: "types.ts" | "wire-types.ts";
|
|
19
|
+
readonly camelCase: boolean;
|
|
20
|
+
} | {
|
|
21
|
+
readonly plugin: "zod";
|
|
22
|
+
readonly name: "zod-schema.ts";
|
|
23
|
+
readonly camelCase: boolean;
|
|
24
|
+
} | {
|
|
25
|
+
readonly plugin: "tanstack-query";
|
|
26
|
+
readonly name: "client.ts";
|
|
27
|
+
readonly camelCase: boolean;
|
|
28
|
+
readonly clientName: string;
|
|
29
|
+
} | {
|
|
30
|
+
readonly plugin: "standard-schema";
|
|
31
|
+
readonly name: "standard-schema.ts";
|
|
32
|
+
readonly camelCase: boolean;
|
|
33
|
+
};
|
|
34
|
+
interface PlanOptions {
|
|
35
|
+
/** Positional plugin names, as typed. */
|
|
36
|
+
readonly plugins: ReadonlyArray<string>;
|
|
37
|
+
/** `--output`, as typed. */
|
|
38
|
+
readonly output: Option.Option<string>;
|
|
39
|
+
/** Basename of the resolved output directory. */
|
|
40
|
+
readonly outputName: Option.Option<string>;
|
|
41
|
+
readonly name: Option.Option<string>;
|
|
42
|
+
readonly camelCase: boolean;
|
|
43
|
+
readonly wireTypes: boolean;
|
|
44
|
+
readonly check: boolean;
|
|
45
|
+
}
|
|
46
|
+
/** Converts a `ClientName` (single `-` or `_` separators) to PascalCase. */
|
|
47
|
+
export declare function pascalCase(value: string): string;
|
|
48
|
+
/**
|
|
49
|
+
* Validates a `generate` invocation and lists the files it writes, sorted by name.
|
|
50
|
+
* Fails with the message shown to the user.
|
|
51
|
+
*/
|
|
52
|
+
export declare function planFiles(options: PlanOptions): Result.Result<ReadonlyArray<OutputFile>, string>;
|
|
53
|
+
export {};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { Array, Option, Order, pipe, Predicate, Result, Schema, String } from "effect";
|
|
2
|
+
const pluginNames = ["standard-schema", "tanstack-query", "typescript", "zod"];
|
|
3
|
+
export const ClientName = Schema.String.pipe(Schema.check(Schema.isPattern(/^[a-zA-Z][a-zA-Z0-9]*(?:[-_][a-zA-Z0-9]+)*$/, {
|
|
4
|
+
message: "--name must start with a letter and contain only letters, numbers, and single hyphen or underscore separators.",
|
|
5
|
+
})));
|
|
6
|
+
export const plugins = {
|
|
7
|
+
"standard-schema": {
|
|
8
|
+
generator: {
|
|
9
|
+
specifier: "@shipworthy/openapi-standard-schema",
|
|
10
|
+
exportName: "standardSchema",
|
|
11
|
+
},
|
|
12
|
+
types: false,
|
|
13
|
+
},
|
|
14
|
+
"tanstack-query": {
|
|
15
|
+
generator: {
|
|
16
|
+
specifier: "@shipworthy/openapi-tanstack-query/generator",
|
|
17
|
+
exportName: "tanstackQuery",
|
|
18
|
+
},
|
|
19
|
+
types: true,
|
|
20
|
+
},
|
|
21
|
+
typescript: {
|
|
22
|
+
generator: { specifier: "@shipworthy/openapi-typescript", exportName: "typescript" },
|
|
23
|
+
types: true,
|
|
24
|
+
},
|
|
25
|
+
zod: {
|
|
26
|
+
generator: { specifier: "@shipworthy/openapi-zod", exportName: "zod" },
|
|
27
|
+
types: true,
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
const isPluginName = Schema.is(Schema.Literals(pluginNames));
|
|
31
|
+
const isClientName = Schema.is(ClientName);
|
|
32
|
+
const byName = Order.mapInput(Order.String, (file) => file.name);
|
|
33
|
+
/** Converts a `ClientName` (single `-` or `_` separators) to PascalCase. */
|
|
34
|
+
export function pascalCase(value) {
|
|
35
|
+
return pipe(value, String.split(/[-_]/), Array.map(String.capitalize), Array.join(""));
|
|
36
|
+
}
|
|
37
|
+
function isFilePath(output) {
|
|
38
|
+
return /\.(?:[mc]?ts|[mc]?js|json)$/i.test(output);
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Validates a `generate` invocation and lists the files it writes, sorted by name.
|
|
42
|
+
* Fails with the message shown to the user.
|
|
43
|
+
*/
|
|
44
|
+
export function planFiles(options) {
|
|
45
|
+
const unknown = Array.findFirst(options.plugins, Predicate.not(isPluginName));
|
|
46
|
+
if (Option.isSome(unknown)) {
|
|
47
|
+
return Result.fail(`Unknown plugin: ${unknown.value}. Available plugins: ${Array.join(pluginNames, ", ")}.`);
|
|
48
|
+
}
|
|
49
|
+
const selected = Array.dedupe(Array.filter(options.plugins, isPluginName));
|
|
50
|
+
const types = Array.some(selected, (plugin) => plugins[plugin].types);
|
|
51
|
+
if (options.wireTypes && !options.camelCase) {
|
|
52
|
+
return Result.fail("--wire-types requires --camel-case.");
|
|
53
|
+
}
|
|
54
|
+
if (options.wireTypes && !types) {
|
|
55
|
+
return Result.fail("--wire-types requires typescript, zod, or tanstack-query.");
|
|
56
|
+
}
|
|
57
|
+
if (options.check && Option.isNone(options.output)) {
|
|
58
|
+
return Result.fail("--check requires --output.");
|
|
59
|
+
}
|
|
60
|
+
const camelCase = options.camelCase;
|
|
61
|
+
const client = Array.contains(selected, "tanstack-query");
|
|
62
|
+
// Every file but client.ts, whose name is resolved only once the other checks pass.
|
|
63
|
+
const files = [
|
|
64
|
+
...(types ? [{ plugin: "typescript", name: "types.ts", camelCase }] : []),
|
|
65
|
+
...(options.wireTypes
|
|
66
|
+
? [{ plugin: "typescript", name: "wire-types.ts", camelCase: false }]
|
|
67
|
+
: []),
|
|
68
|
+
...(Array.contains(selected, "zod")
|
|
69
|
+
? [{ plugin: "zod", name: "zod-schema.ts", camelCase }]
|
|
70
|
+
: []),
|
|
71
|
+
...(Array.contains(selected, "standard-schema")
|
|
72
|
+
? [{ plugin: "standard-schema", name: "standard-schema.ts", camelCase }]
|
|
73
|
+
: []),
|
|
74
|
+
];
|
|
75
|
+
if (files.length + (client ? 1 : 0) > 1 && Option.isNone(options.output)) {
|
|
76
|
+
return Result.fail("--output is required when generating more than one file.");
|
|
77
|
+
}
|
|
78
|
+
if (Option.exists(options.output, isFilePath)) {
|
|
79
|
+
return Result.fail("--output must be a directory. Each plugin writes its own file inside it.");
|
|
80
|
+
}
|
|
81
|
+
if (!client)
|
|
82
|
+
return Result.succeed(Array.sort(files, byName));
|
|
83
|
+
return Result.map(clientName(options), (clientName) => Array.sort([...files, { plugin: "tanstack-query", name: "client.ts", camelCase, clientName }], byName));
|
|
84
|
+
}
|
|
85
|
+
/** `--name`, or the output directory's basename when it is a valid client name. */
|
|
86
|
+
function clientName(options) {
|
|
87
|
+
if (Option.isSome(options.name))
|
|
88
|
+
return Result.succeed(options.name.value);
|
|
89
|
+
return Option.match(Option.filter(options.outputName, isClientName), {
|
|
90
|
+
onSome: Result.succeed,
|
|
91
|
+
onNone: () => Result.fail(`Cannot derive a client name from the output directory "${Option.getOrElse(options.outputName, () => "")}". Pass --name.`),
|
|
92
|
+
});
|
|
93
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type RunPluginOptions } from "@shipworthy/openapi-generator";
|
|
2
|
+
import { Effect, Path } from "effect";
|
|
3
|
+
import { CliError } from "effect/cli";
|
|
4
|
+
import { type TypeFlags } from "./flags.ts";
|
|
5
|
+
import { type OutputFile } from "./plan.ts";
|
|
6
|
+
interface RenderContext {
|
|
7
|
+
readonly model: RunPluginOptions["model"];
|
|
8
|
+
/** Schema text the stamps hash. */
|
|
9
|
+
readonly text: string;
|
|
10
|
+
/** Absolute output directory. */
|
|
11
|
+
readonly directory: string;
|
|
12
|
+
readonly typeFlags: TypeFlags;
|
|
13
|
+
/** Whether output goes to stdout, so generators must not log. */
|
|
14
|
+
readonly silent: boolean;
|
|
15
|
+
}
|
|
16
|
+
export interface RenderedFile {
|
|
17
|
+
readonly name: OutputFile["name"];
|
|
18
|
+
readonly contents: string;
|
|
19
|
+
}
|
|
20
|
+
/** Runs the plugin for `file` against the loaded model and prepends its stamped header. */
|
|
21
|
+
export declare const renderFile: (file: OutputFile, context: RenderContext) => Effect.Effect<{
|
|
22
|
+
name: "client.ts" | "standard-schema.ts" | "types.ts" | "wire-types.ts" | "zod-schema.ts";
|
|
23
|
+
contents: string;
|
|
24
|
+
}, import("@shipworthy/openapi-generator").InvalidPlugin | import("@shipworthy/openapi-generator").MissingPlugin | import("@shipworthy/openapi-generator").TransformError | CliError.UserError, import("effect/crypto/Digest").Digest | import("effect/FileSystem").FileSystem | Path.Path>;
|
|
25
|
+
export {};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { runPlugin } from "@shipworthy/openapi-generator";
|
|
2
|
+
import { Array, Effect, Path, pipe, Predicate, Schema } from "effect";
|
|
3
|
+
import { CliError } from "effect/cli";
|
|
4
|
+
import { pluginOptions } from "./flags.js";
|
|
5
|
+
import { pascalCase, plugins } from "./plan.js";
|
|
6
|
+
import { fileHeader, generatorStamp } from "./stamp.js";
|
|
7
|
+
const AstNodes = Schema.Array(Schema.declare((value) => Predicate.hasProperty(value, "kind")));
|
|
8
|
+
const typescriptModule = Effect.tryPromise({
|
|
9
|
+
try: () => import("@shipworthy/openapi-typescript"),
|
|
10
|
+
catch: (cause) => new CliError.UserError({ cause }),
|
|
11
|
+
});
|
|
12
|
+
function asUserError(cause) {
|
|
13
|
+
return new CliError.UserError({ cause });
|
|
14
|
+
}
|
|
15
|
+
function generate(run, options) {
|
|
16
|
+
return runPlugin({
|
|
17
|
+
...plugins[run.file.plugin].generator,
|
|
18
|
+
model: run.context.model,
|
|
19
|
+
pluginOptions: options,
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
/** Text output from a string-returning plugin, after the header. */
|
|
23
|
+
function renderText(run, options) {
|
|
24
|
+
return generate(run, options).pipe(Effect.flatMap((value) => Effect.mapError(Schema.decodeUnknownEffect(Schema.String)(value), asUserError)), Effect.map((body) => `${run.header}${body}`));
|
|
25
|
+
}
|
|
26
|
+
/** Prints the `typescript` plugin's AST and formats it together with the header. */
|
|
27
|
+
const renderTypes = Effect.fnUntraced(function* (run) {
|
|
28
|
+
const nodes = yield* generate(run, pluginOptions({ ...run.context.typeFlags, camelCase: run.file.camelCase }, run.context.silent)).pipe(Effect.flatMap((value) => Effect.mapError(Schema.decodeUnknownEffect(AstNodes)(value), asUserError)));
|
|
29
|
+
const types = yield* typescriptModule;
|
|
30
|
+
// Print each top-level declaration on its own so a blank line separates them.
|
|
31
|
+
const body = pipe(nodes, Array.map((node) => types.astToString(node)), Array.join("\n"));
|
|
32
|
+
return yield* Effect.tryPromise({
|
|
33
|
+
try: () => types.formatTypeScript(`${run.header}${body}`),
|
|
34
|
+
catch: asUserError,
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
function renderContents(run) {
|
|
38
|
+
const file = run.file;
|
|
39
|
+
switch (file.plugin) {
|
|
40
|
+
case "typescript":
|
|
41
|
+
return renderTypes(run);
|
|
42
|
+
case "zod":
|
|
43
|
+
return renderText(run, { outputPath: run.outputPath, camelCase: file.camelCase });
|
|
44
|
+
case "tanstack-query":
|
|
45
|
+
return renderText(run, {
|
|
46
|
+
prefix: pascalCase(file.clientName),
|
|
47
|
+
name: file.clientName,
|
|
48
|
+
camelCase: file.camelCase,
|
|
49
|
+
});
|
|
50
|
+
case "standard-schema":
|
|
51
|
+
return renderText(run, { camelCase: file.camelCase });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/** Runs the plugin for `file` against the loaded model and prepends its stamped header. */
|
|
55
|
+
export const renderFile = Effect.fn("renderFile")(function* (file, context) {
|
|
56
|
+
const path = yield* Path.Path;
|
|
57
|
+
const generator = plugins[file.plugin].generator;
|
|
58
|
+
const stamp = yield* generatorStamp(generator.specifier, context.text, file.camelCase);
|
|
59
|
+
const header = fileHeader(stamp, file.camelCase);
|
|
60
|
+
const outputPath = path.join(context.directory, file.name);
|
|
61
|
+
const contents = yield* renderContents({ file, context, header, outputPath });
|
|
62
|
+
return { name: file.name, contents };
|
|
63
|
+
});
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { Effect, FileSystem, Stdio } from "effect";
|
|
2
|
+
import { HttpClient } from "effect/http";
|
|
3
|
+
export declare const readStdin: () => Effect.Effect<string, import("effect/PlatformError").PlatformError, Stdio.Stdio>;
|
|
4
|
+
/** The schema text at `source`, read once to stamp generated files. */
|
|
5
|
+
export declare const readSource: (source: URL) => Effect.Effect<string, import("effect/http/HttpClientError").HttpClientError | import("effect/PlatformError").PlatformError, FileSystem.FileSystem | HttpClient.HttpClient>;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { fileURLToPath } from "node:url";
|
|
2
|
+
import { Effect, FileSystem, Stdio, Stream } from "effect";
|
|
3
|
+
import { HttpClient } from "effect/http";
|
|
4
|
+
export const readStdin = Effect.fn("readStdin")(function* () {
|
|
5
|
+
const stdio = yield* Stdio.Stdio;
|
|
6
|
+
return yield* stdio.stdin.pipe(Stream.decodeText(), Stream.mkString);
|
|
7
|
+
});
|
|
8
|
+
/** The schema text at `source`, read once to stamp generated files. */
|
|
9
|
+
export const readSource = Effect.fn("readSource")(function* (source) {
|
|
10
|
+
if (source.protocol === "file:") {
|
|
11
|
+
const fs = yield* FileSystem.FileSystem;
|
|
12
|
+
return yield* fs.readFileString(fileURLToPath(source));
|
|
13
|
+
}
|
|
14
|
+
const http = yield* HttpClient.HttpClient;
|
|
15
|
+
const client = HttpClient.filterStatusOk(http);
|
|
16
|
+
const response = yield* client.get(source.href);
|
|
17
|
+
return yield* response.text;
|
|
18
|
+
});
|
|
@@ -1,13 +1,17 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Effect, FileSystem, Path } from "effect";
|
|
2
2
|
import { CliError } from "effect/cli";
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
export declare const generatorStamp: (specifier: string, source: string | URL) => Effect.Effect<{
|
|
6
|
-
name: string;
|
|
7
|
-
stamp: string;
|
|
8
|
-
}, import("effect/http/HttpClientError").HttpClientError | import("effect/PlatformError").PlatformError | CliError.UserError, Crypto.Crypto | FileSystem.FileSystem | HttpClient.HttpClient | Path.Path>;
|
|
9
|
-
export declare function fileHeader(generator: {
|
|
3
|
+
import { Digest } from "effect/crypto";
|
|
4
|
+
interface Stamp {
|
|
10
5
|
readonly name: string;
|
|
11
6
|
readonly stamp: string;
|
|
12
|
-
}
|
|
13
|
-
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* `<package>@<version>:<sha256 of the schema text>` for the generator at `specifier`.
|
|
10
|
+
* camelCase output hashes the text with a casing marker, so toggling the flag changes the stamp.
|
|
11
|
+
*/
|
|
12
|
+
export declare const generatorStamp: (specifier: string, text: string, camelCase: boolean) => Effect.Effect<{
|
|
13
|
+
name: string;
|
|
14
|
+
stamp: string;
|
|
15
|
+
}, CliError.UserError, Digest.Digest | FileSystem.FileSystem | Path.Path>;
|
|
16
|
+
export declare function fileHeader(generator: Stamp, camelCase: boolean): string;
|
|
17
|
+
export {};
|
|
@@ -1,33 +1,32 @@
|
|
|
1
1
|
import { fileURLToPath } from "node:url";
|
|
2
|
-
import {
|
|
2
|
+
import { Array, Effect, FileSystem, Option, Path, pipe, Schema, String } from "effect";
|
|
3
3
|
import { CliError } from "effect/cli";
|
|
4
|
-
import {
|
|
4
|
+
import { Digest } from "effect/crypto";
|
|
5
5
|
const PackageJson = Schema.Struct({ name: Schema.String, version: Schema.String });
|
|
6
|
-
/**
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
6
|
+
/**
|
|
7
|
+
* `<package>@<version>:<sha256 of the schema text>` for the generator at `specifier`.
|
|
8
|
+
* camelCase output hashes the text with a casing marker, so toggling the flag changes the stamp.
|
|
9
|
+
*/
|
|
10
|
+
export const generatorStamp = Effect.fn("generatorStamp")(function* (specifier, text, camelCase) {
|
|
10
11
|
const generator = yield* generatorPackage(specifier);
|
|
11
|
-
const hash = yield* sha256(
|
|
12
|
+
const hash = yield* sha256(camelCase ? `${text}\0camel-case` : text);
|
|
12
13
|
return { name: generator.name, stamp: `${generator.name}@${generator.version}:${hash}` };
|
|
13
14
|
});
|
|
14
|
-
export function fileHeader(generator) {
|
|
15
|
+
export function fileHeader(generator, camelCase) {
|
|
16
|
+
const casingLine = camelCase ? " * Field casing: camelCase\n" : "";
|
|
15
17
|
return `/**
|
|
16
18
|
* This file was auto-generated by ${generator.name}.
|
|
17
19
|
* Do not make direct changes to the file.
|
|
18
20
|
*
|
|
19
21
|
* ${generator.stamp}
|
|
20
|
-
*/
|
|
21
|
-
|
|
22
|
-
`;
|
|
23
|
-
}
|
|
24
|
-
export function readStamp(text) {
|
|
25
|
-
return stampPattern.exec(text)?.[1];
|
|
22
|
+
${casingLine} */
|
|
23
|
+
\n`;
|
|
26
24
|
}
|
|
27
25
|
const generatorPackage = Effect.fn("generatorPackage")(function* (specifier) {
|
|
28
26
|
const fs = yield* FileSystem.FileSystem;
|
|
29
27
|
const path = yield* Path.Path;
|
|
30
|
-
const
|
|
28
|
+
const segments = pipe(specifier, String.split("/"));
|
|
29
|
+
const name = pipe(segments, Array.take(pipe(specifier, String.startsWith("@")) ? 2 : 1), Array.join("/"));
|
|
31
30
|
const resolved = yield* Effect.try({
|
|
32
31
|
try: () => fileURLToPath(import.meta.resolve(specifier)),
|
|
33
32
|
catch: (cause) => new CliError.UserError({ cause }),
|
|
@@ -44,18 +43,7 @@ const generatorPackage = Effect.fn("generatorPackage")(function* (specifier) {
|
|
|
44
43
|
}
|
|
45
44
|
}
|
|
46
45
|
});
|
|
47
|
-
const readSource = Effect.fn("readSource")(function* (source) {
|
|
48
|
-
if (source.protocol === "file:") {
|
|
49
|
-
const fs = yield* FileSystem.FileSystem;
|
|
50
|
-
return yield* fs.readFileString(fileURLToPath(source));
|
|
51
|
-
}
|
|
52
|
-
const http = yield* HttpClient.HttpClient;
|
|
53
|
-
const client = HttpClient.filterStatusOk(http);
|
|
54
|
-
const response = yield* client.get(source.href);
|
|
55
|
-
return yield* response.text;
|
|
56
|
-
});
|
|
57
46
|
const sha256 = Effect.fn("sha256")(function* (text) {
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
return [...digest].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
47
|
+
const digest = yield* Digest.Digest;
|
|
48
|
+
return yield* digest.digestHex(Digest.sha256, new TextEncoder().encode(text));
|
|
61
49
|
});
|
package/dist/commands/input.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { Effect, Path } from "effect";
|
|
1
|
+
import { Effect, Path, pipe, String } from "effect";
|
|
2
2
|
import { CliError } from "effect/cli";
|
|
3
3
|
export const schemaSource = Effect.fn("schemaSource")(function* (input) {
|
|
4
|
-
if (input.includes("*")) {
|
|
4
|
+
if (pipe(input, String.includes("*"))) {
|
|
5
5
|
return yield* new CliError.UserError({ cause: "Globbing is unsupported." });
|
|
6
6
|
}
|
|
7
7
|
if (/^(https?:|file:)/i.test(input))
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { applyOverlayWithWarnings } from "@shipworthy/openapi-overlay";
|
|
2
2
|
import { bundleDocument } from "@shipworthy/openapi-parser";
|
|
3
3
|
import { Console, Effect, FileSystem, Path, Schema } from "effect";
|
|
4
4
|
import { Argument, Command, Flag } from "effect/cli";
|
|
@@ -11,11 +11,12 @@ const run = Effect.fn("overlay")(function* ({ input, overlay, output, removeUnus
|
|
|
11
11
|
const text = overlay.protocol === "file:"
|
|
12
12
|
? yield* fs.readFileString(fileURLToPath(overlay))
|
|
13
13
|
: yield* HttpClient.get(overlay.href).pipe(Effect.flatMap(HttpClientResponse.filterStatusOk), Effect.flatMap((response) => response.text));
|
|
14
|
-
const result = yield*
|
|
14
|
+
const result = yield* applyOverlayWithWarnings(document, text, { removeUnusedComponents });
|
|
15
|
+
yield* Effect.forEach(result.warnings, (warning) => Console.warn(`WARNING Overlay action ${warning.action} (${warning.target}): ${warning.message}`), { discard: true });
|
|
15
16
|
const path = yield* Path.Path;
|
|
16
17
|
const outputPath = path.resolve(output);
|
|
17
18
|
yield* fs.makeDirectory(path.dirname(outputPath), { recursive: true });
|
|
18
|
-
const serialized = yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown, { space: 2 }))(result);
|
|
19
|
+
const serialized = yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown, { space: 2 }))(result.document);
|
|
19
20
|
yield* fs.writeFileString(outputPath, `${serialized}\n`);
|
|
20
21
|
yield* Console.log(`${input.href} + ${overlay.href} → ${output}`);
|
|
21
22
|
}, Effect.mapError(asCliError));
|
package/package.json
CHANGED
|
@@ -1,22 +1,23 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shipworthy/openapi-cli",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "1.0.0",
|
|
4
4
|
"files": [
|
|
5
5
|
"dist"
|
|
6
6
|
],
|
|
7
7
|
"dependencies": {
|
|
8
|
-
"@effect/platform-node": "
|
|
9
|
-
"@
|
|
10
|
-
"@shipworthy/openapi-
|
|
11
|
-
"@shipworthy/openapi-
|
|
12
|
-
"@shipworthy/
|
|
13
|
-
"
|
|
8
|
+
"@effect/platform-node": "4.1.4",
|
|
9
|
+
"@effect/platform-node-shared": "4.1.4",
|
|
10
|
+
"@shipworthy/openapi-generator": "1.0.0",
|
|
11
|
+
"@shipworthy/openapi-overlay": "1.0.0",
|
|
12
|
+
"@shipworthy/openapi-parser": "1.0.0",
|
|
13
|
+
"@shipworthy/openapi-validator": "1.0.0",
|
|
14
|
+
"effect": "4.1.4"
|
|
14
15
|
},
|
|
15
16
|
"peerDependencies": {
|
|
16
|
-
"@shipworthy/openapi-standard-schema": "0.0
|
|
17
|
-
"@shipworthy/openapi-tanstack-query": "0.0
|
|
18
|
-
"@shipworthy/openapi-typescript": "0.0
|
|
19
|
-
"@shipworthy/openapi-zod": "0.0
|
|
17
|
+
"@shipworthy/openapi-standard-schema": "1.0.0",
|
|
18
|
+
"@shipworthy/openapi-tanstack-query": "1.0.0",
|
|
19
|
+
"@shipworthy/openapi-typescript": "1.0.0",
|
|
20
|
+
"@shipworthy/openapi-zod": "1.0.0"
|
|
20
21
|
},
|
|
21
22
|
"bin": {
|
|
22
23
|
"openapi": "./dist/bin.js"
|
|
@@ -40,14 +41,14 @@
|
|
|
40
41
|
"build": "rm -rf dist && tsc -p tsconfig.build.json && sed -i.bak '1s|^#!.*|#!/usr/bin/env node|' dist/bin.js && rm dist/bin.js.bak"
|
|
41
42
|
},
|
|
42
43
|
"devDependencies": {
|
|
43
|
-
"@shipworthy/openapi-standard-schema": "0.0
|
|
44
|
-
"@shipworthy/openapi-tanstack-query": "0.0
|
|
45
|
-
"@shipworthy/openapi-typescript": "0.0
|
|
46
|
-
"@shipworthy/openapi-zod": "0.0
|
|
44
|
+
"@shipworthy/openapi-standard-schema": "1.0.0",
|
|
45
|
+
"@shipworthy/openapi-tanstack-query": "1.0.0",
|
|
46
|
+
"@shipworthy/openapi-typescript": "1.0.0",
|
|
47
|
+
"@shipworthy/openapi-zod": "1.0.0",
|
|
47
48
|
"@shipworthy/typescript-config": "0.0.1",
|
|
48
|
-
"@types/node": "^
|
|
49
|
+
"@types/node": "^26.6.6",
|
|
49
50
|
"typescript": "^7.0.2",
|
|
50
|
-
"vitest": "5.0
|
|
51
|
+
"vitest": "5.1.0"
|
|
51
52
|
},
|
|
52
53
|
"peerDependenciesMeta": {
|
|
53
54
|
"@shipworthy/openapi-standard-schema": {
|
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
import { Effect, FileSystem, Path } from "effect";
|
|
2
|
-
import { CliError } from "effect/cli";
|
|
3
|
-
export interface EnsureTarget {
|
|
4
|
-
readonly source: URL;
|
|
5
|
-
readonly directory: string;
|
|
6
|
-
readonly started: number;
|
|
7
|
-
readonly force: boolean;
|
|
8
|
-
}
|
|
9
|
-
export declare const ensureDependencies: (dependencies: readonly string[], target: EnsureTarget) => Effect.Effect<void, import("effect/PlatformError").BadArgument | import("effect/http/HttpClientError").HttpClientError | import("effect/PlatformError").PlatformError | import("effect/Schema").SchemaError | CliError.UserError | import("@shipworthy/openapi-generator").GenerateError, import("effect/Crypto").Crypto | import("effect/Stdio").Stdio | (FileSystem.FileSystem | import("effect/http/HttpClient").HttpClient | Path.Path)>;
|
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
import { Effect, FileSystem, Option, Path } from "effect";
|
|
2
|
-
import { CliError } from "effect/cli";
|
|
3
|
-
import { defaultTypeFlags } from "./plugins/typescript/flags.js";
|
|
4
|
-
import { renderTypescript, typescriptSpecifier } from "./plugins/typescript/plugin.js";
|
|
5
|
-
import { generatorStamp, readStamp } from "./stamp.js";
|
|
6
|
-
export const ensureDependencies = Effect.fn("ensureDependencies")(function* (dependencies, target) {
|
|
7
|
-
yield* Effect.forEach(dependencies, (dependency) => dependency === "typescript"
|
|
8
|
-
? ensureTypescript(target)
|
|
9
|
-
: Effect.fail(new CliError.UserError({ cause: `Unknown generator dependency: ${dependency}.` })), { discard: true });
|
|
10
|
-
});
|
|
11
|
-
const ensureTypescript = Effect.fn("ensureTypescript")(function* (target) {
|
|
12
|
-
const fs = yield* FileSystem.FileSystem;
|
|
13
|
-
const path = yield* Path.Path;
|
|
14
|
-
const typesPath = path.join(target.directory, "types.ts");
|
|
15
|
-
const generator = yield* generatorStamp(typescriptSpecifier, target.source);
|
|
16
|
-
if (!target.force) {
|
|
17
|
-
const types = yield* fs.readFileString(typesPath).pipe(Effect.option);
|
|
18
|
-
if (Option.isSome(types) && readStamp(types.value) === generator.stamp)
|
|
19
|
-
return;
|
|
20
|
-
}
|
|
21
|
-
yield* renderTypescript({
|
|
22
|
-
source: target.source,
|
|
23
|
-
generator,
|
|
24
|
-
outputPath: typesPath,
|
|
25
|
-
flags: defaultTypeFlags,
|
|
26
|
-
check: false,
|
|
27
|
-
label: target.source.href,
|
|
28
|
-
outputLabel: typesPath,
|
|
29
|
-
started: target.started,
|
|
30
|
-
});
|
|
31
|
-
});
|
|
@@ -1,4 +0,0 @@
|
|
|
1
|
-
import { Effect, FileSystem, Path, Schema } from "effect";
|
|
2
|
-
export declare function pascalCase(value: string): string;
|
|
3
|
-
export declare const ClientName: Schema.String;
|
|
4
|
-
export declare const writeGenerated: (directory: string, name: string, files: Record<string, string>) => Effect.Effect<void, import("effect/PlatformError").PlatformError, FileSystem.FileSystem | Path.Path>;
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
import { Console, Effect, FileSystem, Path, Schema } from "effect";
|
|
2
|
-
export function pascalCase(value) {
|
|
3
|
-
return value.replace(/(?:^|[-_])([a-zA-Z0-9])/g, (_, letter) => letter.toUpperCase());
|
|
4
|
-
}
|
|
5
|
-
export const ClientName = Schema.String.pipe(Schema.check(Schema.isPattern(/^[a-zA-Z][a-zA-Z0-9]*(?:[-_][a-zA-Z0-9]+)*$/, {
|
|
6
|
-
message: "--name must start with a letter and contain only letters, numbers, and single hyphen or underscore separators.",
|
|
7
|
-
})));
|
|
8
|
-
export const writeGenerated = Effect.fn("writeGenerated")(function* (directory, name, files) {
|
|
9
|
-
const fs = yield* FileSystem.FileSystem;
|
|
10
|
-
const path = yield* Path.Path;
|
|
11
|
-
const output = path.resolve(directory, name);
|
|
12
|
-
yield* fs.makeDirectory(output, { recursive: true });
|
|
13
|
-
yield* Effect.forEach(Object.entries(files), ([file, contents]) => fs.writeFileString(path.join(output, file), contents), { discard: true });
|
|
14
|
-
const written = Object.keys(files).map((file) => `${output}/${file}`);
|
|
15
|
-
yield* Console.log(`Generated ${written.join(", ")}`);
|
|
16
|
-
});
|