Files
cli/scripts/generate.ts
Mauricio Siu ac02c614a6 refactor: migrate to biome and update CLI structure
- Removed ESLint configuration and ignore files in favor of Biome for linting.
- Introduced biome.json for configuration and updated package.json to reflect new dependencies.
- Added OpenAPI specification for Dokploy API and generated CLI commands from it.
- Refactored CLI entry point to use Commander instead of Oclif.
- Implemented new authentication command and removed deprecated commands.
- Updated TypeScript configuration and added build scripts for improved development workflow.
- Cleaned up unused files and commands to streamline the codebase.
2026-04-15 18:37:45 -06:00

287 lines
8.0 KiB
TypeScript

/**
* Generates CLI commands from the Dokploy OpenAPI spec.
*
* Usage: npx tsx scripts/generate.ts
*
* Reads openapi.json from the project root and generates:
* - src/generated/commands.ts (all CLI commands)
*/
import * as fs from "node:fs";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, "..");
const SPEC_PATH = path.join(ROOT, "openapi.json");
const OUT_PATH = path.join(ROOT, "src", "generated", "commands.ts");
interface OpenAPISpec {
paths: Record<string, Record<string, OperationObject>>;
}
interface OperationObject {
operationId?: string;
summary?: string;
description?: string;
tags?: string[];
parameters?: ParameterObject[];
requestBody?: {
content?: {
"application/json"?: {
schema?: SchemaObject;
};
};
};
responses?: Record<string, unknown>;
}
interface ParameterObject {
name: string;
in: string;
required?: boolean;
schema?: SchemaObject;
}
interface SchemaObject {
type?: string;
properties?: Record<string, SchemaProperty>;
required?: string[];
anyOf?: SchemaObject[];
items?: SchemaObject;
enum?: string[];
}
interface SchemaProperty {
type?: string;
anyOf?: SchemaObject[];
enum?: string[];
default?: unknown;
description?: string;
}
interface CommandInfo {
/** e.g. "application.create" */
endpoint: string;
/** e.g. "application" */
group: string;
/** e.g. "create" */
action: string;
method: "get" | "post";
description: string;
options: OptionInfo[];
}
interface OptionInfo {
name: string;
flag: string;
description: string;
required: boolean;
type: "string" | "number" | "boolean";
enumValues?: string[];
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function resolveType(prop: SchemaProperty): "string" | "number" | "boolean" {
const raw = prop.type ?? prop.anyOf?.find((s) => s.type && s.type !== "null")?.type;
if (raw === "number" || raw === "integer") return "number";
if (raw === "boolean") return "boolean";
return "string";
}
function resolveEnum(prop: SchemaProperty): string[] | undefined {
if (prop.enum) return prop.enum;
const inner = prop.anyOf?.find((s) => s.enum);
return inner?.enum;
}
function extractOptionsFromSchema(schema: SchemaObject | undefined): OptionInfo[] {
if (!schema?.properties) return [];
const required = new Set(schema.required ?? []);
return Object.entries(schema.properties).map(([name, prop]) => {
const type = resolveType(prop);
const enumValues = resolveEnum(prop);
let desc = prop.description ?? name;
if (enumValues) desc += ` (${enumValues.join(", ")})`;
return {
name,
flag: `--${name} <${type === "boolean" ? "" : "value"}>`.replace(/ <>/g, ""),
description: desc,
required: required.has(name),
type,
enumValues,
};
});
}
function extractOptionsFromParams(params: ParameterObject[]): OptionInfo[] {
return params.map((p) => {
const type = resolveType(p.schema ?? {});
const enumValues = resolveEnum(p.schema ?? {});
let desc = p.name;
if (enumValues) desc += ` (${enumValues.join(", ")})`;
return {
name: p.name,
flag: `--${p.name} <${type === "boolean" ? "" : "value"}>`.replace(/ <>/g, ""),
description: desc,
required: p.required ?? false,
type,
enumValues,
};
});
}
function camelToKebab(s: string): string {
return s.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase();
}
// ---------------------------------------------------------------------------
// Parse spec → CommandInfo[]
// ---------------------------------------------------------------------------
function parseSpec(spec: OpenAPISpec): CommandInfo[] {
const commands: CommandInfo[] = [];
for (const [pathKey, methods] of Object.entries(spec.paths)) {
for (const [method, op] of Object.entries(methods)) {
const endpoint = pathKey.replace(/^\//, "");
const [group, ...rest] = endpoint.split(".");
const action = rest.join(".");
if (!group || !action) continue;
const bodySchema = op.requestBody?.content?.["application/json"]?.schema;
const paramOptions = op.parameters ? extractOptionsFromParams(op.parameters) : [];
const bodyOptions = extractOptionsFromSchema(bodySchema);
const options = [...paramOptions, ...bodyOptions];
commands.push({
endpoint,
group,
action,
method: method as "get" | "post",
description: op.summary ?? op.description ?? `${group} ${action}`,
options,
});
}
}
return commands.sort((a, b) => a.endpoint.localeCompare(b.endpoint));
}
// ---------------------------------------------------------------------------
// Code generation
// ---------------------------------------------------------------------------
function generateOptionLine(opt: OptionInfo): string {
const flag = opt.type === "boolean"
? `--${opt.name}`
: `--${opt.name} <value>`;
const escaped = opt.description.replace(/'/g, "\\'");
return opt.required
? `.requiredOption('${flag}', '${escaped}')`
: `.option('${flag}', '${escaped}')`;
}
function generateCoercion(opt: OptionInfo): string {
if (opt.type === "number") {
return `if (opts["${opt.name}"] != null) opts["${opt.name}"] = Number(opts["${opt.name}"]);`;
}
if (opt.type === "boolean") {
return `if (opts["${opt.name}"] != null) opts["${opt.name}"] = opts["${opt.name}"] === true || opts["${opt.name}"] === "true";`;
}
return "";
}
function generateCommandCode(cmd: CommandInfo, groupVar: string): string {
const actionName = camelToKebab(cmd.action);
const optionLines = cmd.options.map(generateOptionLine).join("\n\t\t");
const coercions = cmd.options
.map(generateCoercion)
.filter(Boolean)
.map((c) => `\t\t\t${c}`)
.join("\n");
const apiCall = cmd.method === "post"
? `await apiPost("${cmd.endpoint}", opts)`
: `await apiGet("${cmd.endpoint}", opts)`;
const escapedDesc = cmd.description.replace(/'/g, "\\'");
return `
${groupVar}
.command('${actionName}')
.description('${escapedDesc}')
${optionLines}
.option('--json', 'Output raw JSON')
.action(async (opts: Record<string, any>) => {
const jsonOutput = opts.json; delete opts.json;
${coercions}
const data = ${apiCall};
if (jsonOutput) {
console.log(JSON.stringify(data, null, 2));
} else {
printOutput(data);
}
});`;
}
function generateFile(commands: CommandInfo[]): string {
// Group commands by their group name
const groups = new Map<string, CommandInfo[]>();
for (const cmd of commands) {
const existing = groups.get(cmd.group) ?? [];
existing.push(cmd);
groups.set(cmd.group, existing);
}
const groupBlocks: string[] = [];
for (const [group, cmds] of [...groups.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
const varName = `g_${group.replace(/[^a-zA-Z0-9]/g, "_")}`;
const kebabGroup = camelToKebab(group);
groupBlocks.push(`\tconst ${varName} = program.command('${kebabGroup}').description('${kebabGroup} commands');`);
for (const cmd of cmds) {
groupBlocks.push(generateCommandCode(cmd, varName));
}
}
return `// Auto-generated from openapi.json — do not edit manually.
// Run: npx tsx scripts/generate.ts
import type { Command } from "commander";
import chalk from "chalk";
import { apiPost, apiGet } from "../client.js";
function printOutput(data: unknown) {
if (data === null || data === undefined) {
console.log(chalk.green("OK"));
return;
}
if (typeof data === "string") {
console.log(data);
return;
}
console.log(JSON.stringify(data, null, 2));
}
export function registerGeneratedCommands(program: Command) {
${groupBlocks.join("\n")}
}
`;
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
const spec: OpenAPISpec = JSON.parse(fs.readFileSync(SPEC_PATH, "utf8"));
const commands = parseSpec(spec);
fs.mkdirSync(path.dirname(OUT_PATH), { recursive: true });
fs.writeFileSync(OUT_PATH, generateFile(commands));
console.log(`Generated ${commands.length} commands → ${path.relative(ROOT, OUT_PATH)}`);