Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 53 additions & 16 deletions scripts/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ interface OperationObject {
"application/json"?: {
schema?: SchemaObject;
};
"multipart/form-data"?: {
schema?: SchemaObject;
};
};
};
responses?: Record<string, unknown>;
Expand All @@ -54,6 +57,7 @@ interface SchemaObject {

interface SchemaProperty {
type?: string;
format?: string;
anyOf?: SchemaObject[];
enum?: string[];
default?: unknown;
Expand All @@ -70,6 +74,7 @@ interface CommandInfo {
method: "get" | "post";
description: string;
options: OptionInfo[];
isMultipart?: boolean;
}

interface OptionInfo {
Expand All @@ -78,6 +83,7 @@ interface OptionInfo {
description: string;
required: boolean;
type: "string" | "number" | "boolean";
format?: string;
enumValues?: string[];
}

Expand All @@ -86,7 +92,8 @@ interface OptionInfo {
// ---------------------------------------------------------------------------

function resolveType(prop: SchemaProperty): "string" | "number" | "boolean" {
const raw = prop.type ?? prop.anyOf?.find((s) => s.type && s.type !== "null")?.type;
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";
Expand All @@ -98,7 +105,9 @@ function resolveEnum(prop: SchemaProperty): string[] | undefined {
return inner?.enum;
}

function extractOptionsFromSchema(schema: SchemaObject | undefined): OptionInfo[] {
function extractOptionsFromSchema(
schema: SchemaObject | undefined,
): OptionInfo[] {
if (!schema?.properties) return [];
const required = new Set(schema.required ?? []);
return Object.entries(schema.properties).map(([name, prop]) => {
Expand All @@ -108,10 +117,14 @@ function extractOptionsFromSchema(schema: SchemaObject | undefined): OptionInfo[
if (enumValues) desc += ` (${enumValues.join(", ")})`;
return {
name,
flag: `--${name} <${type === "boolean" ? "" : "value"}>`.replace(/ <>/g, ""),
flag: `--${name} <${type === "boolean" ? "" : "value"}>`.replace(
/ <>/g,
"",
),
description: desc,
required: required.has(name),
type,
format: prop.format,
enumValues,
};
});
Expand All @@ -125,7 +138,10 @@ function extractOptionsFromParams(params: ParameterObject[]): OptionInfo[] {
if (enumValues) desc += ` (${enumValues.join(", ")})`;
return {
name: p.name,
flag: `--${p.name} <${type === "boolean" ? "" : "value"}>`.replace(/ <>/g, ""),
flag: `--${p.name} <${type === "boolean" ? "" : "value"}>`.replace(
/ <>/g,
"",
),
description: desc,
required: p.required ?? false,
type,
Expand Down Expand Up @@ -153,8 +169,15 @@ function parseSpec(spec: OpenAPISpec): CommandInfo[] {

if (!group || !action) continue;

const bodySchema = op.requestBody?.content?.["application/json"]?.schema;
const paramOptions = op.parameters ? extractOptionsFromParams(op.parameters) : [];
const jsonSchema = op.requestBody?.content?.["application/json"]?.schema;
const multipartSchema =
op.requestBody?.content?.["multipart/form-data"]?.schema;
const bodySchema = jsonSchema ?? multipartSchema;
const isMultipart = !!multipartSchema;

const paramOptions = op.parameters
? extractOptionsFromParams(op.parameters)
: [];
const bodyOptions = extractOptionsFromSchema(bodySchema);
const options = [...paramOptions, ...bodyOptions];

Expand All @@ -165,6 +188,7 @@ function parseSpec(spec: OpenAPISpec): CommandInfo[] {
method: method as "get" | "post",
description: op.summary ?? op.description ?? `${group} ${action}`,
options,
isMultipart,
});
}
}
Expand All @@ -177,9 +201,8 @@ function parseSpec(spec: OpenAPISpec): CommandInfo[] {
// ---------------------------------------------------------------------------

function generateOptionLine(opt: OptionInfo): string {
const flag = opt.type === "boolean"
? `--${opt.name}`
: `--${opt.name} <value>`;
const flag =
opt.type === "boolean" ? `--${opt.name}` : `--${opt.name} <value>`;
const escaped = opt.description.replace(/'/g, "\\'");
return opt.required
? `.requiredOption('${flag}', '${escaped}')`
Expand All @@ -205,9 +228,17 @@ function generateCommandCode(cmd: CommandInfo, groupVar: string): string {
.map((c) => `\t\t\t${c}`)
.join("\n");

const apiCall = cmd.method === "post"
? `await apiPost("${cmd.endpoint}", opts)`
: `await apiGet("${cmd.endpoint}", opts)`;
let apiCall =
cmd.method === "post"
? `await apiPost("${cmd.endpoint}", opts)`
: `await apiGet("${cmd.endpoint}", opts)`;

if (cmd.isMultipart) {
const fileFields = cmd.options
.filter((o) => o.format === "binary")
.map((o) => o.name);
apiCall = `await apiPostForm("${cmd.endpoint}", opts, ${JSON.stringify(fileFields)})`;
}

const escapedDesc = cmd.description.replace(/'/g, "\\'");

Expand Down Expand Up @@ -239,10 +270,14 @@ function generateFile(commands: CommandInfo[]): string {
}

const groupBlocks: string[] = [];
for (const [group, cmds] of [...groups.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
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');`);
groupBlocks.push(
`\tconst ${varName} = program.command('${kebabGroup}').description('${kebabGroup} commands');`,
);
for (const cmd of cmds) {
groupBlocks.push(generateCommandCode(cmd, varName));
}
Expand All @@ -253,7 +288,7 @@ function generateFile(commands: CommandInfo[]): string {

import type { Command } from "commander";
import chalk from "chalk";
import { apiPost, apiGet } from "../client.js";
import { apiPost, apiGet, apiPostForm } from "../client.js";

function printOutput(data: unknown) {
if (data === null || data === undefined) {
Expand Down Expand Up @@ -283,4 +318,6 @@ 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)}`);
console.log(
`Generated ${commands.length} commands → ${path.relative(ROOT, OUT_PATH)}`,
);
30 changes: 29 additions & 1 deletion src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,10 @@ function loadEnvFile(): void {
const eqIndex = trimmed.indexOf("=");
if (eqIndex === -1) continue;
const key = trimmed.slice(0, eqIndex).trim();
const value = trimmed.slice(eqIndex + 1).trim().replace(/^["']|["']$/g, "");
const value = trimmed
.slice(eqIndex + 1)
.trim()
.replace(/^["']|["']$/g, "");
if (!process.env[key]) {
process.env[key] = value;
}
Expand Down Expand Up @@ -93,6 +96,31 @@ export async function apiPost(
return response.data?.result?.data?.json ?? response.data;
}

export async function apiPostForm(
endpoint: string,
data: Record<string, unknown>,
fileFields: string[],
) {
const client = createClient();
const formData: Record<string, unknown> = {};

for (const [key, value] of Object.entries(data)) {
if (value === undefined) continue;
if (fileFields.includes(key)) {
const filePath = String(value);
if (!fs.existsSync(filePath)) {
throw new Error(`File not found: ${filePath}`);
}
formData[key] = fs.createReadStream(filePath);
} else {
formData[key] = value;
}
}

const response = await client.postForm(`/trpc/${endpoint}`, formData);
return response.data?.result?.data?.json ?? response.data;
}

export async function apiGet(
endpoint: string,
params?: Record<string, unknown>,
Expand Down
15 changes: 10 additions & 5 deletions src/generated/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

import type { Command } from "commander";
import chalk from "chalk";
import { apiPost, apiGet } from "../client.js";
import { apiPost, apiGet, apiPostForm } from "../client.js";

function printOutput(data: unknown) {
if (data === null || data === undefined) {
Expand Down Expand Up @@ -409,12 +409,14 @@ export function registerGeneratedCommands(program: Command) {
g_application
.command('drop-deployment')
.description('application dropDeployment')

.requiredOption('--applicationId <value>', 'applicationId')
.requiredOption('--zip <value>', 'zip')
.option('--dropBuildPath <value>', 'dropBuildPath')
.option('--json', 'Output raw JSON')
.action(async (opts: Record<string, any>) => {
const jsonOutput = opts.json; delete opts.json;

const data = await apiPost("application.dropDeployment", opts);
const data = await apiPostForm("application.dropDeployment", opts, ["zip"]);
if (jsonOutput) {
console.log(JSON.stringify(data, null, 2));
} else {
Expand Down Expand Up @@ -2953,12 +2955,15 @@ export function registerGeneratedCommands(program: Command) {
g_docker
.command('upload-file-to-container')
.description('docker uploadFileToContainer')

.requiredOption('--containerId <value>', 'containerId')
.requiredOption('--file <value>', 'file')
.requiredOption('--destinationPath <value>', 'destinationPath')
.option('--serverId <value>', 'serverId')
.option('--json', 'Output raw JSON')
.action(async (opts: Record<string, any>) => {
const jsonOutput = opts.json; delete opts.json;

const data = await apiPost("docker.uploadFileToContainer", opts);
const data = await apiPostForm("docker.uploadFileToContainer", opts, ["file"]);
if (jsonOutput) {
console.log(JSON.stringify(data, null, 2));
} else {
Expand Down
3 changes: 2 additions & 1 deletion tests/generator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,8 @@ describe("generator", () => {
if (group && rest.length > 0) validEndpoints++;
}

const apiCalls = (content.match(/await api(Post|Get)\(/g) || []).length;
const apiCalls = (content.match(/await api(Post|Get|PostForm)\(/g) || [])
.length;
expect(apiCalls).toBe(validEndpoints);
});
});