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 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. If you try to use them without a JSON content type, specli errors.
303
- - Required fields in the schema are checked in a "friendly" way:
304
- - `Missing required body field 'name'. Provide --name or use --data/--file.`
305
- - If a body field flag conflicts with an operation parameter flag, the operation parameter takes precedence.
306
- - Numeric coercion uses `Number(...)` / `parseInt(...)`. Today it does not explicitly reject `NaN` (this is an area to harden).
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
@@ -2,7 +2,7 @@
2
2
  "name": "specli",
3
3
  "module": "index.ts",
4
4
  "type": "module",
5
- "version": "0.0.2",
5
+ "version": "0.0.4",
6
6
  "bin": {
7
7
  "specli": "./cli.ts"
8
8
  },
@@ -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
- if (input.globals.dryRun) {
38
- process.stdout.write(`${request.method} ${request.url}\n`);
39
- for (const [k, v] of request.headers.entries()) {
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
- try {
60
- const res = await fetch(request, { signal: controller?.signal });
61
- const contentType = res.headers.get("content-type") ?? "";
62
- const status = res.status;
41
+ const text = await res.text();
42
+ let body: unknown = text;
43
+ let parsedJson: unknown | undefined;
63
44
 
64
- const text = await res.text();
65
- let body: unknown = text;
66
- let parsedJson: unknown | undefined;
67
-
68
- if (contentType.includes("json")) {
69
- try {
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
- const payload: unknown =
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.stdout.write(text);
119
- if (!text.endsWith("\n")) process.stdout.write("\n");
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
- } finally {
122
- if (timeout) clearTimeout(timeout);
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
- const reservedFlags = new Set(action.flags.map((f) => f.flag));
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
- // Common curl-replacement options.
84
- // Only add flags that don't conflict with operation flags.
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
- const url = new URL(
205
- path,
206
- serverUrl.endsWith("/") ? serverUrl : `${serverUrl}/`,
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 hasExpandedBody = bodyFlagDefs.some((def) => {
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
- if (hasData && hasFile) throw new Error("Use only one of --data or --file");
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 (!hasExpandedBody && !hasData && !hasFile) {
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 if (hasExpandedBody) {
282
+ } else {
318
283
  if (!contentType?.includes("json")) {
319
284
  throw new Error(
320
- "Body field flags are only supported for JSON request bodies. Use --content-type application/json or --data/--file.",
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]} or use --data/--file.`,
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
- const base = input.serverOverride ?? input.servers[0]?.url;
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.",