specli 0.0.2 → 0.0.4
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 +5 -49
- package/package.json +1 -1
- package/src/cli/compile.ts +13 -8
- package/src/cli/runtime/execute.ts +34 -79
- package/src/cli/runtime/generated.ts +6 -40
- package/src/cli/runtime/request.ts +13 -89
- package/src/cli/runtime/server-url.ts +2 -1
package/README.md
CHANGED
|
@@ -201,50 +201,6 @@ Implementation notes:
|
|
|
201
201
|
|
|
202
202
|
## Request Bodies
|
|
203
203
|
|
|
204
|
-
### Selecting the Body Input
|
|
205
|
-
|
|
206
|
-
If an operation has a `requestBody`, you may provide a body via:
|
|
207
|
-
|
|
208
|
-
- `--data <string>`
|
|
209
|
-
- `--file <path>`
|
|
210
|
-
- Body field flags (JSON-only; see below)
|
|
211
|
-
|
|
212
|
-
Rules:
|
|
213
|
-
|
|
214
|
-
- `--data` and `--file` are mutually exclusive.
|
|
215
|
-
- Body field flags cannot be used with `--data` or `--file`.
|
|
216
|
-
- If `requestBody.required` is true and you provide none of the above, the command fails with:
|
|
217
|
-
- `Missing request body. Provide --data, --file, or body field flags.`
|
|
218
|
-
|
|
219
|
-
### Content-Type
|
|
220
|
-
|
|
221
|
-
`Content-Type` is chosen as:
|
|
222
|
-
|
|
223
|
-
1. `--content-type` (explicit override)
|
|
224
|
-
2. The preferred content type derived from the OpenAPI requestBody (prefers `application/json` when present)
|
|
225
|
-
|
|
226
|
-
### JSON Parsing + Normalization
|
|
227
|
-
|
|
228
|
-
If the selected `Content-Type` includes `json`:
|
|
229
|
-
|
|
230
|
-
- `--data`/`--file` content is parsed as either JSON or YAML
|
|
231
|
-
- the request is sent as normalized JSON (`JSON.stringify(parsed)`)
|
|
232
|
-
|
|
233
|
-
If `Content-Type` does not include `json`:
|
|
234
|
-
|
|
235
|
-
- the body is treated as a raw string
|
|
236
|
-
|
|
237
|
-
### Schema Validation (Ajv)
|
|
238
|
-
|
|
239
|
-
specli uses Ajv (best-effort, `strict: false`) to validate:
|
|
240
|
-
|
|
241
|
-
- query/header/cookie params
|
|
242
|
-
- JSON request bodies when a requestBody schema is available
|
|
243
|
-
|
|
244
|
-
Validation errors are formatted into a readable multiline message. For `required` errors, the message is normalized to:
|
|
245
|
-
|
|
246
|
-
- `/<path> missing required property '<name>'`
|
|
247
|
-
|
|
248
204
|
### Body Field Flags
|
|
249
205
|
|
|
250
206
|
When an operation has a `requestBody` and the preferred schema is a JSON object, specli generates convenience flags that match the property names:
|
|
@@ -299,11 +255,11 @@ Which produces:
|
|
|
299
255
|
|
|
300
256
|
Notes / edge cases:
|
|
301
257
|
|
|
302
|
-
- Body field flags are only supported for JSON bodies.
|
|
303
|
-
- Required fields in the schema are checked
|
|
304
|
-
- `Missing required body field 'name'. Provide --name
|
|
305
|
-
- If a body field flag
|
|
306
|
-
- Numeric coercion uses `Number(...)` / `parseInt(...)`.
|
|
258
|
+
- Body field flags are only supported for JSON bodies.
|
|
259
|
+
- Required fields in the schema are checked with friendly error messages:
|
|
260
|
+
- `Missing required body field 'name'. Provide --name.`
|
|
261
|
+
- If a body field flag would conflict with an operation parameter flag or `--curl`, the operation parameter takes precedence.
|
|
262
|
+
- Numeric coercion uses `Number(...)` / `parseInt(...)`.
|
|
307
263
|
|
|
308
264
|
## Servers
|
|
309
265
|
|
package/package.json
CHANGED
package/src/cli/compile.ts
CHANGED
|
@@ -65,18 +65,23 @@ export async function compileCommand(
|
|
|
65
65
|
|
|
66
66
|
buildArgs.push("./src/compiled.ts");
|
|
67
67
|
|
|
68
|
+
// Only set env vars that have actual values - avoid empty strings
|
|
69
|
+
// because the macros will embed them and they will override defaults.
|
|
70
|
+
const buildEnv: Record<string, string> = {
|
|
71
|
+
...process.env,
|
|
72
|
+
SPECLI_SPEC: spec,
|
|
73
|
+
SPECLI_NAME: name,
|
|
74
|
+
};
|
|
75
|
+
if (options.server) buildEnv.SPECLI_SERVER = options.server;
|
|
76
|
+
if (options.serverVar?.length)
|
|
77
|
+
buildEnv.SPECLI_SERVER_VARS = options.serverVar.join(",");
|
|
78
|
+
if (options.auth) buildEnv.SPECLI_AUTH = options.auth;
|
|
79
|
+
|
|
68
80
|
const proc = Bun.spawn({
|
|
69
81
|
cmd: ["bun", ...buildArgs],
|
|
70
82
|
stdout: "pipe",
|
|
71
83
|
stderr: "pipe",
|
|
72
|
-
env:
|
|
73
|
-
...process.env,
|
|
74
|
-
SPECLI_SPEC: spec,
|
|
75
|
-
SPECLI_NAME: name,
|
|
76
|
-
SPECLI_SERVER: options.server ?? "",
|
|
77
|
-
SPECLI_SERVER_VARS: options.serverVar?.join(",") ?? "",
|
|
78
|
-
SPECLI_AUTH: options.auth ?? "",
|
|
79
|
-
},
|
|
84
|
+
env: buildEnv,
|
|
80
85
|
});
|
|
81
86
|
|
|
82
87
|
const output = await new Response(proc.stdout).text();
|
|
@@ -34,92 +34,47 @@ export async function executeAction(input: ExecuteInput): Promise<void> {
|
|
|
34
34
|
return;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
process.stdout.write(`${k}: ${v}\n`);
|
|
41
|
-
}
|
|
42
|
-
if (request.body) {
|
|
43
|
-
const text = await request.clone().text();
|
|
44
|
-
if (text) process.stdout.write(`\n${text}\n`);
|
|
45
|
-
}
|
|
46
|
-
return;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
const timeoutMs = input.globals.timeout
|
|
50
|
-
? Number(input.globals.timeout)
|
|
51
|
-
: undefined;
|
|
52
|
-
let timeout: Timer | undefined;
|
|
53
|
-
let controller: AbortController | undefined;
|
|
54
|
-
if (timeoutMs && Number.isFinite(timeoutMs) && timeoutMs > 0) {
|
|
55
|
-
controller = new AbortController();
|
|
56
|
-
timeout = setTimeout(() => controller?.abort(), timeoutMs);
|
|
57
|
-
}
|
|
37
|
+
const res = await fetch(request);
|
|
38
|
+
const contentType = res.headers.get("content-type") ?? "";
|
|
39
|
+
const status = res.status;
|
|
58
40
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
const status = res.status;
|
|
41
|
+
const text = await res.text();
|
|
42
|
+
let body: unknown = text;
|
|
43
|
+
let parsedJson: unknown | undefined;
|
|
63
44
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
parsedJson = text ? JSON.parse(text) : null;
|
|
71
|
-
body = parsedJson;
|
|
72
|
-
} catch {
|
|
73
|
-
// keep as text
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
if (!res.ok) {
|
|
78
|
-
if (input.globals.json) {
|
|
79
|
-
process.stdout.write(
|
|
80
|
-
`${JSON.stringify({
|
|
81
|
-
status,
|
|
82
|
-
body,
|
|
83
|
-
headers: input.globals.headers
|
|
84
|
-
? Object.fromEntries(res.headers.entries())
|
|
85
|
-
: undefined,
|
|
86
|
-
})}\n`,
|
|
87
|
-
);
|
|
88
|
-
} else {
|
|
89
|
-
process.stderr.write(`HTTP ${status}\n`);
|
|
90
|
-
process.stderr.write(
|
|
91
|
-
`${typeof body === "string" ? body : JSON.stringify(body, null, 2)}\n`,
|
|
92
|
-
);
|
|
93
|
-
}
|
|
94
|
-
process.exitCode = 1;
|
|
95
|
-
return;
|
|
45
|
+
if (contentType.includes("json")) {
|
|
46
|
+
try {
|
|
47
|
+
parsedJson = text ? JSON.parse(text) : null;
|
|
48
|
+
body = parsedJson;
|
|
49
|
+
} catch {
|
|
50
|
+
// keep as text
|
|
96
51
|
}
|
|
52
|
+
}
|
|
97
53
|
|
|
54
|
+
if (!res.ok) {
|
|
98
55
|
if (input.globals.json) {
|
|
99
|
-
|
|
100
|
-
input.globals.status || input.globals.headers
|
|
101
|
-
? {
|
|
102
|
-
status: input.globals.status ? status : undefined,
|
|
103
|
-
headers: input.globals.headers
|
|
104
|
-
? Object.fromEntries(res.headers.entries())
|
|
105
|
-
: undefined,
|
|
106
|
-
body,
|
|
107
|
-
}
|
|
108
|
-
: body;
|
|
109
|
-
|
|
110
|
-
process.stdout.write(`${JSON.stringify(payload)}\n`);
|
|
111
|
-
return;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
// default (human + agent readable)
|
|
115
|
-
if (typeof parsedJson !== "undefined") {
|
|
116
|
-
process.stdout.write(`${JSON.stringify(parsedJson, null, 2)}\n`);
|
|
56
|
+
process.stdout.write(`${JSON.stringify({ status, body })}\n`);
|
|
117
57
|
} else {
|
|
118
|
-
process.
|
|
119
|
-
|
|
58
|
+
process.stderr.write(`HTTP ${status}\n`);
|
|
59
|
+
process.stderr.write(
|
|
60
|
+
`${typeof body === "string" ? body : JSON.stringify(body, null, 2)}\n`,
|
|
61
|
+
);
|
|
120
62
|
}
|
|
121
|
-
|
|
122
|
-
|
|
63
|
+
process.exitCode = 1;
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (input.globals.json) {
|
|
68
|
+
process.stdout.write(`${JSON.stringify(body)}\n`);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// default (human + agent readable)
|
|
73
|
+
if (typeof parsedJson !== "undefined") {
|
|
74
|
+
process.stdout.write(`${JSON.stringify(parsedJson, null, 2)}\n`);
|
|
75
|
+
} else {
|
|
76
|
+
process.stdout.write(text);
|
|
77
|
+
if (!text.endsWith("\n")) process.stdout.write("\n");
|
|
123
78
|
}
|
|
124
79
|
} catch (err) {
|
|
125
80
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -5,7 +5,6 @@ import type { CommandModel } from "../command-model.ts";
|
|
|
5
5
|
import type { ServerInfo } from "../server.ts";
|
|
6
6
|
|
|
7
7
|
import { type BodyFlagDef, generateBodyFlags } from "./body-flags.ts";
|
|
8
|
-
import { collectRepeatable } from "./collect.ts";
|
|
9
8
|
import { executeAction } from "./execute.ts";
|
|
10
9
|
import type { EmbeddedDefaults } from "./request.ts";
|
|
11
10
|
import { coerceArrayInput, coerceValue } from "./validate/index.ts";
|
|
@@ -78,54 +77,21 @@ export function addGeneratedCommands(
|
|
|
78
77
|
else cmd.option(key, desc, parser);
|
|
79
78
|
}
|
|
80
79
|
|
|
81
|
-
|
|
80
|
+
// Collect reserved flags: operation params + --curl
|
|
81
|
+
const operationFlags = new Set(action.flags.map((f) => f.flag));
|
|
82
|
+
const reservedFlags = new Set([...operationFlags, "--curl"]);
|
|
82
83
|
|
|
83
|
-
//
|
|
84
|
-
|
|
85
|
-
if (!reservedFlags.has("--header")) {
|
|
86
|
-
cmd.option(
|
|
87
|
-
"--header <header>",
|
|
88
|
-
"Extra header (repeatable)",
|
|
89
|
-
collectRepeatable,
|
|
90
|
-
);
|
|
91
|
-
}
|
|
92
|
-
if (!reservedFlags.has("--accept")) {
|
|
93
|
-
cmd.option("--accept <type>", "Override Accept header");
|
|
94
|
-
}
|
|
95
|
-
if (!reservedFlags.has("--status")) {
|
|
96
|
-
cmd.option("--status", "Include status in --json output");
|
|
97
|
-
}
|
|
98
|
-
if (!reservedFlags.has("--headers")) {
|
|
99
|
-
cmd.option("--headers", "Include headers in --json output");
|
|
100
|
-
}
|
|
101
|
-
if (!reservedFlags.has("--dry-run")) {
|
|
102
|
-
cmd.option("--dry-run", "Print request without sending");
|
|
103
|
-
}
|
|
104
|
-
if (!reservedFlags.has("--curl")) {
|
|
84
|
+
// Only --curl is a built-in flag (for debugging)
|
|
85
|
+
if (!operationFlags.has("--curl")) {
|
|
105
86
|
cmd.option("--curl", "Print curl command without sending");
|
|
106
87
|
}
|
|
107
|
-
if (!reservedFlags.has("--timeout")) {
|
|
108
|
-
cmd.option("--timeout <ms>", "Request timeout in milliseconds");
|
|
109
|
-
}
|
|
110
88
|
|
|
111
89
|
// Track body flag definitions for this action
|
|
112
90
|
let bodyFlagDefs: BodyFlagDef[] = [];
|
|
113
91
|
|
|
114
92
|
if (action.requestBody) {
|
|
115
|
-
if (!reservedFlags.has("--data")) {
|
|
116
|
-
cmd.option("--data <data>", "Inline request body");
|
|
117
|
-
}
|
|
118
|
-
if (!reservedFlags.has("--file")) {
|
|
119
|
-
cmd.option("--file <path>", "Request body from file");
|
|
120
|
-
}
|
|
121
|
-
if (!reservedFlags.has("--content-type")) {
|
|
122
|
-
cmd.option(
|
|
123
|
-
"--content-type <type>",
|
|
124
|
-
"Override Content-Type (defaults from OpenAPI)",
|
|
125
|
-
);
|
|
126
|
-
}
|
|
127
|
-
|
|
128
93
|
// Generate body flags from schema (recursive with dot notation)
|
|
94
|
+
// Pass reserved flags to avoid conflicts with operation params and --curl
|
|
129
95
|
bodyFlagDefs = generateBodyFlags(
|
|
130
96
|
action.requestBodySchema,
|
|
131
97
|
reservedFlags,
|
|
@@ -2,8 +2,6 @@ import type { AuthScheme } from "../auth-schemes.ts";
|
|
|
2
2
|
import type { CommandAction } from "../command-model.ts";
|
|
3
3
|
|
|
4
4
|
import { resolveAuthScheme } from "./auth/resolve.ts";
|
|
5
|
-
import { loadBody, parseBodyAsJsonOrYaml } from "./body.ts";
|
|
6
|
-
import { parseHeaderInput } from "./headers.ts";
|
|
7
5
|
import { getToken } from "./profile/secrets.ts";
|
|
8
6
|
import { getProfile, readProfiles } from "./profile/store.ts";
|
|
9
7
|
import { resolveServerUrl } from "./server-url.ts";
|
|
@@ -19,18 +17,7 @@ export type RuntimeGlobals = {
|
|
|
19
17
|
server?: string;
|
|
20
18
|
serverVar?: string[];
|
|
21
19
|
|
|
22
|
-
// Common runtime flags.
|
|
23
|
-
header?: string[];
|
|
24
|
-
accept?: string;
|
|
25
|
-
contentType?: string;
|
|
26
|
-
data?: string;
|
|
27
|
-
file?: string;
|
|
28
|
-
timeout?: string;
|
|
29
|
-
dryRun?: boolean;
|
|
30
20
|
curl?: boolean;
|
|
31
|
-
status?: boolean;
|
|
32
|
-
headers?: boolean;
|
|
33
|
-
|
|
34
21
|
json?: boolean;
|
|
35
22
|
|
|
36
23
|
auth?: string;
|
|
@@ -201,14 +188,14 @@ export async function buildRequest(
|
|
|
201
188
|
|
|
202
189
|
const path = applyTemplate(input.action.path, pathVars, { encode: true });
|
|
203
190
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
)
|
|
191
|
+
// Build the full URL by combining server URL and path.
|
|
192
|
+
// We need to handle the case where path starts with "/" carefully:
|
|
193
|
+
// URL constructor treats absolute paths as relative to origin, not base path.
|
|
194
|
+
const baseUrl = serverUrl.endsWith("/") ? serverUrl : `${serverUrl}/`;
|
|
195
|
+
const relativePath = path.startsWith("/") ? path.slice(1) : path;
|
|
196
|
+
const url = new URL(relativePath, baseUrl);
|
|
208
197
|
|
|
209
198
|
const headers = new Headers();
|
|
210
|
-
const accept = input.globals.accept;
|
|
211
|
-
if (accept) headers.set("Accept", accept);
|
|
212
199
|
|
|
213
200
|
// Collect declared params for validation.
|
|
214
201
|
const queryValues: Record<string, unknown> = {};
|
|
@@ -273,51 +260,29 @@ export async function buildRequest(
|
|
|
273
260
|
headers.set("Cookie", existing ? `${existing}; ${part}` : part);
|
|
274
261
|
}
|
|
275
262
|
|
|
276
|
-
const extraHeaders = (input.globals.header ?? []).map(parseHeaderInput);
|
|
277
|
-
for (const { name, value } of extraHeaders) {
|
|
278
|
-
headers.set(name, value);
|
|
279
|
-
}
|
|
280
|
-
|
|
281
263
|
let body: string | undefined;
|
|
282
264
|
if (input.action.requestBody) {
|
|
283
|
-
const data = input.globals.data;
|
|
284
|
-
const file = input.globals.file;
|
|
285
|
-
|
|
286
|
-
const hasData = typeof data === "string";
|
|
287
|
-
const hasFile = typeof file === "string";
|
|
288
|
-
|
|
289
265
|
// Check if any body flags were provided using the flag definitions
|
|
290
266
|
const bodyFlagDefs = input.bodyFlagDefs ?? [];
|
|
291
|
-
const
|
|
267
|
+
const hasBodyFlags = bodyFlagDefs.some((def) => {
|
|
292
268
|
// Commander keeps dots in option names: --address.street -> "address.street"
|
|
293
269
|
const dotKey = def.path.join(".");
|
|
294
270
|
return input.flagValues[dotKey] !== undefined;
|
|
295
271
|
});
|
|
296
272
|
|
|
297
|
-
|
|
298
|
-
if (hasExpandedBody && (hasData || hasFile)) {
|
|
299
|
-
throw new Error(
|
|
300
|
-
"Use either --data/--file or body field flags (not both)",
|
|
301
|
-
);
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
const contentType =
|
|
305
|
-
input.globals.contentType ??
|
|
306
|
-
input.action.requestBody.preferredContentType;
|
|
273
|
+
const contentType = input.action.requestBody.preferredContentType;
|
|
307
274
|
if (contentType) headers.set("Content-Type", contentType);
|
|
308
275
|
|
|
309
276
|
const schema = input.action.requestBodySchema;
|
|
310
277
|
|
|
311
|
-
if (!
|
|
278
|
+
if (!hasBodyFlags) {
|
|
312
279
|
if (input.action.requestBody.required) {
|
|
313
|
-
throw new Error(
|
|
314
|
-
"Missing request body. Provide --data, --file, or body field flags.",
|
|
315
|
-
);
|
|
280
|
+
throw new Error("Missing required request body fields.");
|
|
316
281
|
}
|
|
317
|
-
} else
|
|
282
|
+
} else {
|
|
318
283
|
if (!contentType?.includes("json")) {
|
|
319
284
|
throw new Error(
|
|
320
|
-
"Body field flags are only supported for JSON request bodies.
|
|
285
|
+
"Body field flags are only supported for JSON request bodies.",
|
|
321
286
|
);
|
|
322
287
|
}
|
|
323
288
|
|
|
@@ -328,7 +293,7 @@ export async function buildRequest(
|
|
|
328
293
|
const missing = findMissingRequired(input.flagValues, bodyFlagDefs);
|
|
329
294
|
if (missing.length > 0) {
|
|
330
295
|
throw new Error(
|
|
331
|
-
`Missing required body field '${missing[0]}'. Provide --${missing[0]}
|
|
296
|
+
`Missing required body field '${missing[0]}'. Provide --${missing[0]}.`,
|
|
332
297
|
);
|
|
333
298
|
}
|
|
334
299
|
|
|
@@ -343,47 +308,6 @@ export async function buildRequest(
|
|
|
343
308
|
}
|
|
344
309
|
|
|
345
310
|
body = JSON.stringify(built);
|
|
346
|
-
} else if (hasData) {
|
|
347
|
-
if (contentType?.includes("json")) {
|
|
348
|
-
const parsed = parseBodyAsJsonOrYaml(data as string);
|
|
349
|
-
|
|
350
|
-
if (schema) {
|
|
351
|
-
const validate = ajv.compile(schema);
|
|
352
|
-
if (!validate(parsed)) {
|
|
353
|
-
throw new Error(formatAjvErrors(validate.errors));
|
|
354
|
-
}
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
body = JSON.stringify(parsed);
|
|
358
|
-
} else {
|
|
359
|
-
body = data as string;
|
|
360
|
-
}
|
|
361
|
-
} else if (hasFile) {
|
|
362
|
-
const loaded = await loadBody({
|
|
363
|
-
kind: "file",
|
|
364
|
-
path: file as string,
|
|
365
|
-
});
|
|
366
|
-
if (contentType?.includes("json")) {
|
|
367
|
-
const parsed = parseBodyAsJsonOrYaml(loaded?.raw ?? "");
|
|
368
|
-
|
|
369
|
-
if (schema) {
|
|
370
|
-
const validate = ajv.compile(schema);
|
|
371
|
-
if (!validate(parsed)) {
|
|
372
|
-
throw new Error(formatAjvErrors(validate.errors));
|
|
373
|
-
}
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
body = JSON.stringify(parsed);
|
|
377
|
-
} else {
|
|
378
|
-
body = loaded?.raw;
|
|
379
|
-
}
|
|
380
|
-
}
|
|
381
|
-
} else {
|
|
382
|
-
if (
|
|
383
|
-
typeof input.globals.data === "string" ||
|
|
384
|
-
typeof input.globals.file === "string"
|
|
385
|
-
) {
|
|
386
|
-
throw new Error("This operation does not accept a request body");
|
|
387
311
|
}
|
|
388
312
|
}
|
|
389
313
|
|
|
@@ -9,7 +9,8 @@ export type ResolveServerInput = {
|
|
|
9
9
|
};
|
|
10
10
|
|
|
11
11
|
export function resolveServerUrl(input: ResolveServerInput): string {
|
|
12
|
-
|
|
12
|
+
// Treat empty string as undefined (serverOverride can come from env vars or profiles)
|
|
13
|
+
const base = input.serverOverride || input.servers[0]?.url;
|
|
13
14
|
if (!base) {
|
|
14
15
|
throw new Error(
|
|
15
16
|
"No server URL found. Provide --server <url> or define servers in the OpenAPI spec.",
|