specli 0.0.21 → 0.0.22

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.
Files changed (31) hide show
  1. package/package.json +3 -2
  2. package/dist/ai/tools.test.d.ts +0 -1
  3. package/dist/ai/tools.test.js +0 -49
  4. package/dist/cli/model/capabilities.test.d.ts +0 -1
  5. package/dist/cli/model/capabilities.test.js +0 -84
  6. package/dist/cli/model/command-id.test.d.ts +0 -1
  7. package/dist/cli/model/command-id.test.js +0 -27
  8. package/dist/cli/model/command-model.test.d.ts +0 -1
  9. package/dist/cli/model/command-model.test.js +0 -40
  10. package/dist/cli/model/naming.test.d.ts +0 -1
  11. package/dist/cli/model/naming.test.js +0 -75
  12. package/dist/cli/parse/auth-requirements.test.d.ts +0 -1
  13. package/dist/cli/parse/auth-requirements.test.js +0 -16
  14. package/dist/cli/parse/auth-schemes.test.d.ts +0 -1
  15. package/dist/cli/parse/auth-schemes.test.js +0 -56
  16. package/dist/cli/parse/operations.test.d.ts +0 -1
  17. package/dist/cli/parse/operations.test.js +0 -51
  18. package/dist/cli/parse/params.test.d.ts +0 -1
  19. package/dist/cli/parse/params.test.js +0 -62
  20. package/dist/cli/parse/positional.test.d.ts +0 -1
  21. package/dist/cli/parse/positional.test.js +0 -60
  22. package/dist/cli/parse/request-body.test.d.ts +0 -1
  23. package/dist/cli/parse/request-body.test.js +0 -31
  24. package/dist/cli/parse/servers.test.d.ts +0 -1
  25. package/dist/cli/parse/servers.test.js +0 -49
  26. package/dist/cli/runtime/body-flags.test.d.ts +0 -1
  27. package/dist/cli/runtime/body-flags.test.js +0 -192
  28. package/dist/cli/runtime/request.test.d.ts +0 -1
  29. package/dist/cli/runtime/request.test.js +0 -332
  30. package/dist/cli/runtime/validate/coerce.test.d.ts +0 -1
  31. package/dist/cli/runtime/validate/coerce.test.js +0 -75
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "specli",
3
- "version": "0.0.21",
3
+ "version": "0.0.22",
4
4
  "type": "module",
5
5
  "module": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -17,7 +17,8 @@
17
17
  "bin",
18
18
  "dist",
19
19
  "README.md",
20
- "package.json"
20
+ "package.json",
21
+ "!**/*.test.*"
21
22
  ],
22
23
  "scripts": {
23
24
  "build": "tsc",
@@ -1 +0,0 @@
1
- export {};
@@ -1,49 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { specli } from "./tools.js";
3
- const mockOptions = {
4
- toolCallId: "test-call-id",
5
- abortSignal: new AbortController().signal,
6
- messages: [],
7
- };
8
- describe("specli tool", () => {
9
- test("creates a tool with correct structure", async () => {
10
- const tool = await specli({
11
- spec: "https://petstore3.swagger.io/api/v3/openapi.json",
12
- });
13
- expect(tool).toHaveProperty("description");
14
- expect(tool).toHaveProperty("inputSchema");
15
- expect(tool).toHaveProperty("execute");
16
- expect(typeof tool.execute).toBe("function");
17
- });
18
- test("list command returns resources", async () => {
19
- const tool = await specli({
20
- spec: "https://petstore3.swagger.io/api/v3/openapi.json",
21
- });
22
- const result = (await tool.execute?.({ command: "list" }, mockOptions));
23
- expect(result).toHaveProperty("resources");
24
- expect(Array.isArray(result.resources)).toBe(true);
25
- });
26
- test("help command returns action details", async () => {
27
- const tool = await specli({
28
- spec: "https://petstore3.swagger.io/api/v3/openapi.json",
29
- });
30
- const result = (await tool.execute?.({ command: "help", resource: "pets", action: "get" }, mockOptions));
31
- expect(result).toHaveProperty("action");
32
- expect(result.action).toBe("get");
33
- });
34
- test("help command with missing resource returns error", async () => {
35
- const tool = await specli({
36
- spec: "https://petstore3.swagger.io/api/v3/openapi.json",
37
- });
38
- const result = (await tool.execute?.({ command: "help" }, mockOptions));
39
- expect(result).toHaveProperty("error");
40
- });
41
- test("exec command with missing args returns error", async () => {
42
- const tool = await specli({
43
- spec: "https://petstore3.swagger.io/api/v3/openapi.json",
44
- });
45
- const result = (await tool.execute?.({ command: "exec", resource: "pets", action: "get" }, mockOptions));
46
- expect(result).toHaveProperty("error");
47
- expect(result.error).toContain("Missing args");
48
- });
49
- });
@@ -1 +0,0 @@
1
- export {};
@@ -1,84 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { deriveCapabilities } from "./capabilities.js";
3
- describe("deriveCapabilities", () => {
4
- test("reports requestBody + server vars", () => {
5
- const doc = {
6
- openapi: "3.0.3",
7
- security: [{ bearerAuth: [] }],
8
- };
9
- const servers = [
10
- {
11
- url: "https://{region}.api.example.com",
12
- variables: [],
13
- variableNames: ["region"],
14
- },
15
- ];
16
- const authSchemes = [
17
- { key: "bearerAuth", kind: "http-bearer" },
18
- ];
19
- const operations = [
20
- {
21
- key: "POST /contacts",
22
- method: "POST",
23
- path: "/contacts",
24
- tags: [],
25
- parameters: [],
26
- requestBody: {
27
- required: true,
28
- contentTypes: ["application/json"],
29
- schemasByContentType: { "application/json": { type: "object" } },
30
- },
31
- },
32
- ];
33
- const commands = {
34
- resources: [
35
- {
36
- resource: "contacts",
37
- actions: [
38
- {
39
- id: "x",
40
- key: "POST /contacts",
41
- action: "create",
42
- pathArgs: [],
43
- method: "POST",
44
- path: "/contacts",
45
- tags: [],
46
- style: "rest",
47
- positionals: [],
48
- flags: [],
49
- params: [],
50
- auth: { alternatives: [] },
51
- requestBody: {
52
- required: true,
53
- content: [
54
- {
55
- contentType: "application/json",
56
- required: true,
57
- schemaType: "object",
58
- },
59
- ],
60
- hasJson: true,
61
- hasFormUrlEncoded: false,
62
- hasMultipart: false,
63
- bodyFlags: ["--data", "--file"],
64
- preferredContentType: "application/json",
65
- },
66
- },
67
- ],
68
- },
69
- ],
70
- };
71
- const caps = deriveCapabilities({
72
- doc,
73
- servers,
74
- authSchemes,
75
- operations,
76
- commands,
77
- });
78
- expect(caps.servers.hasVariables).toBe(true);
79
- expect(caps.operations.hasRequestBodies).toBe(true);
80
- expect(caps.commands.hasRequestBodies).toBe(true);
81
- expect(caps.auth.hasSecurityRequirements).toBe(true);
82
- expect(caps.auth.kinds).toEqual(["http-bearer"]);
83
- });
84
- });
@@ -1 +0,0 @@
1
- export {};
@@ -1,27 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { buildCommandId } from "./command-id.js";
3
- describe("buildCommandId", () => {
4
- test("includes spec/resource/action/op", () => {
5
- expect(buildCommandId({
6
- specId: "contacts-api",
7
- resource: "contacts",
8
- action: "get",
9
- operationKey: "GET /contacts/{id}",
10
- })).toBe("contacts-api:contacts:get:get-contacts-id");
11
- });
12
- test("disambiguates by operationKey", () => {
13
- const a = buildCommandId({
14
- specId: "x",
15
- resource: "contacts",
16
- action: "list",
17
- operationKey: "GET /contacts",
18
- });
19
- const b = buildCommandId({
20
- specId: "x",
21
- resource: "contacts",
22
- action: "list",
23
- operationKey: "GET /contacts/search",
24
- });
25
- expect(a).not.toBe(b);
26
- });
27
- });
@@ -1 +0,0 @@
1
- export {};
@@ -1,40 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { buildCommandModel } from "./command-model.js";
3
- describe("buildCommandModel", () => {
4
- test("groups operations by resource", () => {
5
- const planned = [
6
- {
7
- key: "GET /contacts",
8
- method: "GET",
9
- path: "/contacts",
10
- tags: ["Contacts"],
11
- parameters: [],
12
- resource: "contacts",
13
- action: "list",
14
- canonicalAction: "list",
15
- pathArgs: [],
16
- style: "rest",
17
- },
18
- {
19
- key: "GET /contacts/{id}",
20
- method: "GET",
21
- path: "/contacts/{id}",
22
- tags: ["Contacts"],
23
- parameters: [],
24
- resource: "contacts",
25
- action: "get",
26
- canonicalAction: "get",
27
- pathArgs: ["id"],
28
- style: "rest",
29
- },
30
- ];
31
- const model = buildCommandModel(planned, { specId: "contacts-api" });
32
- expect(model.resources).toHaveLength(1);
33
- expect(model.resources[0]?.resource).toBe("contacts");
34
- expect(model.resources[0]?.actions).toHaveLength(2);
35
- expect(model.resources[0]?.actions.map((a) => a.action)).toEqual([
36
- "get",
37
- "list",
38
- ]);
39
- });
40
- });
@@ -1 +0,0 @@
1
- export {};
@@ -1,75 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { planOperation } from "./naming.js";
3
- describe("planOperation", () => {
4
- test("REST: GET /contacts -> contacts list", () => {
5
- const op = {
6
- key: "GET /contacts",
7
- method: "GET",
8
- path: "/contacts",
9
- operationId: "Contacts.List",
10
- tags: ["Contacts"],
11
- parameters: [],
12
- };
13
- const planned = planOperation(op);
14
- expect(planned.style).toBe("rest");
15
- expect(planned.resource).toBe("contacts");
16
- expect(planned.action).toBe("list");
17
- expect(planned.pathArgs).toEqual([]);
18
- });
19
- test("REST: singleton /ping stays ping and prefers operationId action", () => {
20
- const op = {
21
- key: "GET /ping",
22
- method: "GET",
23
- path: "/ping",
24
- operationId: "Ping.Get",
25
- tags: [],
26
- parameters: [],
27
- };
28
- const planned = planOperation(op);
29
- expect(planned.style).toBe("rest");
30
- expect(planned.resource).toBe("ping");
31
- expect(planned.action).toBe("get");
32
- });
33
- test("REST: singular path pluralizes to contacts", () => {
34
- const op = {
35
- key: "GET /contact/{id}",
36
- method: "GET",
37
- path: "/contact/{id}",
38
- tags: [],
39
- parameters: [],
40
- };
41
- const planned = planOperation(op);
42
- expect(planned.style).toBe("rest");
43
- expect(planned.resource).toBe("contacts");
44
- expect(planned.action).toBe("get");
45
- expect(planned.pathArgs).toEqual(["id"]);
46
- });
47
- test("RPC: POST /Contacts.List -> contacts list", () => {
48
- const op = {
49
- key: "POST /Contacts.List",
50
- method: "POST",
51
- path: "/Contacts.List",
52
- operationId: "Contacts.List",
53
- tags: [],
54
- parameters: [],
55
- };
56
- const planned = planOperation(op);
57
- expect(planned.style).toBe("rpc");
58
- expect(planned.resource).toBe("contacts");
59
- expect(planned.action).toBe("list");
60
- });
61
- test("RPC: Retrieve canonicalizes to get", () => {
62
- const op = {
63
- key: "POST /Contacts.Retrieve",
64
- method: "POST",
65
- path: "/Contacts.Retrieve",
66
- operationId: "Contacts.Retrieve",
67
- tags: [],
68
- parameters: [],
69
- };
70
- const planned = planOperation(op);
71
- expect(planned.style).toBe("rpc");
72
- expect(planned.resource).toBe("contacts");
73
- expect(planned.action).toBe("get");
74
- });
75
- });
@@ -1 +0,0 @@
1
- export {};
@@ -1,16 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { summarizeAuth } from "./auth-requirements.js";
3
- describe("summarizeAuth", () => {
4
- test("uses operation-level security when present", () => {
5
- const schemes = [{ key: "oauth", kind: "oauth2" }];
6
- const summary = summarizeAuth([{ oauth: ["read:ping"] }], [{ oauth: ["read:other"] }], schemes);
7
- expect(summary.alternatives).toEqual([
8
- [{ key: "oauth", scopes: ["read:ping"] }],
9
- ]);
10
- });
11
- test("empty operation security disables auth", () => {
12
- const schemes = [{ key: "oauth", kind: "oauth2" }];
13
- const summary = summarizeAuth([], [{ oauth: ["read:other"] }], schemes);
14
- expect(summary.alternatives).toEqual([]);
15
- });
16
- });
@@ -1 +0,0 @@
1
- export {};
@@ -1,56 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { listAuthSchemes } from "./auth-schemes.js";
3
- describe("listAuthSchemes", () => {
4
- test("parses bearer + apiKey", () => {
5
- const doc = {
6
- openapi: "3.0.3",
7
- components: {
8
- securitySchemes: {
9
- bearerAuth: {
10
- type: "http",
11
- scheme: "bearer",
12
- bearerFormat: "JWT",
13
- },
14
- apiKeyAuth: {
15
- type: "apiKey",
16
- in: "header",
17
- name: "X-API-Key",
18
- },
19
- },
20
- },
21
- };
22
- const schemes = listAuthSchemes(doc);
23
- expect(schemes).toHaveLength(2);
24
- const bearer = schemes.find((s) => s.key === "bearerAuth");
25
- expect(bearer?.kind).toBe("http-bearer");
26
- const apiKey = schemes.find((s) => s.key === "apiKeyAuth");
27
- expect(apiKey?.kind).toBe("api-key");
28
- expect(apiKey?.in).toBe("header");
29
- expect(apiKey?.name).toBe("X-API-Key");
30
- });
31
- test("parses oauth2 flows", () => {
32
- const doc = {
33
- openapi: "3.0.3",
34
- components: {
35
- securitySchemes: {
36
- oauth: {
37
- type: "oauth2",
38
- flows: {
39
- clientCredentials: {
40
- tokenUrl: "https://example.com/oauth/token",
41
- scopes: {
42
- "read:ping": "read ping",
43
- },
44
- },
45
- },
46
- },
47
- },
48
- },
49
- };
50
- const schemes = listAuthSchemes(doc);
51
- const oauth = schemes.find((s) => s.key === "oauth");
52
- expect(oauth?.kind).toBe("oauth2");
53
- expect(oauth?.oauthFlows?.clientCredentials?.tokenUrl).toBe("https://example.com/oauth/token");
54
- expect(oauth?.oauthFlows?.clientCredentials?.scopes).toEqual(["read:ping"]);
55
- });
56
- });
@@ -1 +0,0 @@
1
- export {};
@@ -1,51 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { indexOperations } from "./operations.js";
3
- describe("indexOperations", () => {
4
- test("indexes basic operations", () => {
5
- const doc = {
6
- openapi: "3.0.3",
7
- paths: {
8
- "/contacts": {
9
- get: {
10
- operationId: "Contacts.List",
11
- tags: ["Contacts"],
12
- parameters: [
13
- {
14
- in: "query",
15
- name: "limit",
16
- schema: { type: "integer" },
17
- },
18
- ],
19
- },
20
- },
21
- "/contacts/{id}": {
22
- get: {
23
- operationId: "Contacts.Get",
24
- tags: ["Contacts"],
25
- parameters: [
26
- {
27
- in: "path",
28
- name: "id",
29
- required: true,
30
- schema: { type: "string" },
31
- },
32
- ],
33
- },
34
- },
35
- },
36
- };
37
- const ops = indexOperations(doc);
38
- expect(ops).toHaveLength(2);
39
- expect(ops[0]?.key).toBe("GET /contacts");
40
- expect(ops[0]?.path).toBe("/contacts");
41
- expect(ops[0]?.method).toBe("GET");
42
- expect(ops[0]?.parameters).toHaveLength(1);
43
- expect(ops[0]?.parameters[0]?.in).toBe("query");
44
- expect(ops[1]?.key).toBe("GET /contacts/{id}");
45
- expect(ops[1]?.path).toBe("/contacts/{id}");
46
- expect(ops[1]?.method).toBe("GET");
47
- expect(ops[1]?.parameters).toHaveLength(1);
48
- expect(ops[1]?.parameters[0]?.in).toBe("path");
49
- expect(ops[1]?.parameters[0]?.required).toBe(true);
50
- });
51
- });
@@ -1 +0,0 @@
1
- export {};
@@ -1,62 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { deriveParamSpecs } from "./params.js";
3
- describe("deriveParamSpecs", () => {
4
- test("derives basic types + flags", () => {
5
- const op = {
6
- key: "GET /contacts",
7
- method: "GET",
8
- path: "/contacts",
9
- tags: [],
10
- parameters: [
11
- {
12
- in: "query",
13
- name: "limit",
14
- required: false,
15
- schema: {
16
- type: "integer",
17
- format: "int32",
18
- enum: ["1", "2"],
19
- },
20
- },
21
- {
22
- in: "header",
23
- name: "X-Request-Id",
24
- required: false,
25
- schema: { type: "string" },
26
- },
27
- ],
28
- };
29
- const specs = deriveParamSpecs(op);
30
- expect(specs).toHaveLength(2);
31
- const limit = specs.find((p) => p.name === "limit");
32
- expect(limit?.kind).toBe("flag");
33
- expect(limit?.flag).toBe("--limit");
34
- expect(limit?.type).toBe("integer");
35
- expect(limit?.format).toBe("int32");
36
- expect(limit?.enum).toEqual(["1", "2"]);
37
- const reqId = specs.find((p) => p.name === "X-Request-Id");
38
- expect(reqId?.kind).toBe("flag");
39
- expect(reqId?.flag).toBe("--x-request-id");
40
- expect(reqId?.type).toBe("string");
41
- });
42
- test("derives array item types", () => {
43
- const op = {
44
- key: "GET /things",
45
- method: "GET",
46
- path: "/things",
47
- tags: [],
48
- parameters: [
49
- {
50
- in: "query",
51
- name: "ids",
52
- required: false,
53
- schema: { type: "array", items: { type: "integer" } },
54
- },
55
- ],
56
- };
57
- const specs = deriveParamSpecs(op);
58
- expect(specs).toHaveLength(1);
59
- expect(specs[0]?.type).toBe("array");
60
- expect(specs[0]?.itemType).toBe("integer");
61
- });
62
- });
@@ -1 +0,0 @@
1
- export {};
@@ -1,60 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { deriveFlags, derivePositionals } from "./positional.js";
3
- describe("derivePositionals", () => {
4
- test("returns ordered positionals from pathArgs", () => {
5
- const action = {
6
- pathArgs: ["id"],
7
- params: [
8
- {
9
- kind: "positional",
10
- in: "path",
11
- name: "id",
12
- flag: "--id",
13
- required: true,
14
- type: "string",
15
- },
16
- ],
17
- };
18
- const pos = derivePositionals(action);
19
- expect(pos).toEqual([
20
- {
21
- name: "id",
22
- required: true,
23
- type: "string",
24
- format: undefined,
25
- enum: undefined,
26
- description: undefined,
27
- },
28
- ]);
29
- });
30
- });
31
- describe("deriveFlags", () => {
32
- test("returns only flag params", () => {
33
- const action = {
34
- pathArgs: [],
35
- params: [
36
- {
37
- kind: "flag",
38
- in: "query",
39
- name: "limit",
40
- flag: "--limit",
41
- required: false,
42
- type: "integer",
43
- },
44
- ],
45
- };
46
- const flags = deriveFlags(action);
47
- expect(flags.flags).toEqual([
48
- {
49
- in: "query",
50
- name: "limit",
51
- flag: "--limit",
52
- required: false,
53
- description: undefined,
54
- type: "integer",
55
- format: undefined,
56
- enum: undefined,
57
- },
58
- ]);
59
- });
60
- });
@@ -1 +0,0 @@
1
- export {};
@@ -1,31 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { deriveRequestBodyInfo } from "./request-body.js";
3
- describe("deriveRequestBodyInfo", () => {
4
- test("summarizes content types and convenience flags", () => {
5
- const op = {
6
- key: "POST /contacts",
7
- method: "POST",
8
- path: "/contacts",
9
- tags: [],
10
- parameters: [],
11
- requestBody: {
12
- required: true,
13
- contentTypes: ["application/x-www-form-urlencoded", "application/json"],
14
- schemasByContentType: {
15
- "application/json": { type: "object" },
16
- "application/x-www-form-urlencoded": { type: "object" },
17
- },
18
- },
19
- };
20
- const info = deriveRequestBodyInfo(op);
21
- expect(info?.required).toBe(true);
22
- expect(info?.hasJson).toBe(true);
23
- expect(info?.hasFormUrlEncoded).toBe(true);
24
- expect(info?.hasMultipart).toBe(false);
25
- expect(info?.content.map((c) => c.contentType)).toEqual([
26
- "application/json",
27
- "application/x-www-form-urlencoded",
28
- ]);
29
- expect(info?.preferredSchema).toEqual({ type: "object" });
30
- });
31
- });
@@ -1 +0,0 @@
1
- export {};
@@ -1,49 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { listServers } from "./servers.js";
3
- describe("listServers", () => {
4
- test("extracts server variables from template", () => {
5
- const doc = {
6
- openapi: "3.0.3",
7
- servers: [
8
- {
9
- url: "https://{region}.api.example.com/{basePath}",
10
- variables: {
11
- region: {
12
- default: "us",
13
- enum: ["us", "eu"],
14
- },
15
- basePath: {
16
- default: "v1",
17
- },
18
- },
19
- },
20
- ],
21
- };
22
- const servers = listServers(doc);
23
- expect(servers).toHaveLength(1);
24
- expect(servers[0]?.variableNames).toEqual(["region", "basePath"]);
25
- expect(servers[0]?.variables.map((v) => v.name)).toEqual([
26
- "region",
27
- "basePath",
28
- ]);
29
- expect(servers[0]?.variables[0]?.enum).toEqual(["us", "eu"]);
30
- });
31
- test("includes servers defined on paths and operations", () => {
32
- const doc = {
33
- openapi: "3.0.3",
34
- paths: {
35
- "/v1/forecast": {
36
- servers: [{ url: "https://api.a.example.com" }],
37
- get: {
38
- servers: [{ url: "https://api.b.example.com" }],
39
- },
40
- },
41
- },
42
- };
43
- const servers = listServers(doc);
44
- expect(servers.map((s) => s.url)).toEqual([
45
- "https://api.a.example.com",
46
- "https://api.b.example.com",
47
- ]);
48
- });
49
- });
@@ -1 +0,0 @@
1
- export {};
@@ -1,192 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { findMissingRequired, generateBodyFlags, parseDotNotationFlags, } from "./body-flags.js";
3
- describe("generateBodyFlags", () => {
4
- test("generates flags for simple properties", () => {
5
- const flags = generateBodyFlags({
6
- type: "object",
7
- properties: {
8
- name: { type: "string" },
9
- age: { type: "integer" },
10
- active: { type: "boolean" },
11
- },
12
- required: ["name"],
13
- }, new Set());
14
- expect(flags).toHaveLength(3);
15
- expect(flags.find((f) => f.flag === "--name")).toEqual({
16
- flag: "--name",
17
- path: ["name"],
18
- type: "string",
19
- description: "Body field 'name'",
20
- required: true,
21
- });
22
- expect(flags.find((f) => f.flag === "--age")).toEqual({
23
- flag: "--age",
24
- path: ["age"],
25
- type: "integer",
26
- description: "Body field 'age'",
27
- required: false,
28
- });
29
- });
30
- test("generates dot-notation flags for nested objects", () => {
31
- const flags = generateBodyFlags({
32
- type: "object",
33
- properties: {
34
- name: { type: "string" },
35
- address: {
36
- type: "object",
37
- properties: {
38
- street: { type: "string" },
39
- city: { type: "string" },
40
- zip: { type: "string" },
41
- },
42
- },
43
- },
44
- }, new Set());
45
- expect(flags).toHaveLength(4);
46
- expect(flags.find((f) => f.flag === "--name")).toBeDefined();
47
- expect(flags.find((f) => f.flag === "--address.street")).toEqual({
48
- flag: "--address.street",
49
- path: ["address", "street"],
50
- type: "string",
51
- description: "Body field 'address.street'",
52
- required: false,
53
- });
54
- });
55
- test("handles deeply nested objects", () => {
56
- const flags = generateBodyFlags({
57
- type: "object",
58
- properties: {
59
- user: {
60
- type: "object",
61
- properties: {
62
- profile: {
63
- type: "object",
64
- properties: {
65
- bio: { type: "string" },
66
- },
67
- },
68
- },
69
- },
70
- },
71
- }, new Set());
72
- expect(flags.find((f) => f.flag === "--user.profile.bio")).toEqual({
73
- flag: "--user.profile.bio",
74
- path: ["user", "profile", "bio"],
75
- type: "string",
76
- description: "Body field 'user.profile.bio'",
77
- required: false,
78
- });
79
- });
80
- test("skips reserved flags", () => {
81
- const flags = generateBodyFlags({
82
- type: "object",
83
- properties: {
84
- name: { type: "string" },
85
- data: { type: "string" }, // --data is reserved
86
- },
87
- }, new Set(["--data"]));
88
- expect(flags).toHaveLength(1);
89
- expect(flags[0]?.flag).toBe("--name");
90
- });
91
- test("skips --curl builtin flag", () => {
92
- const reservedFlags = new Set(["--curl"]);
93
- const flags = generateBodyFlags({
94
- type: "object",
95
- properties: {
96
- name: { type: "string" },
97
- curl: { type: "boolean" }, // conflicts with --curl builtin
98
- email: { type: "string" }, // no conflict
99
- },
100
- }, reservedFlags);
101
- expect(flags).toHaveLength(2);
102
- expect(flags.map((f) => f.flag).sort()).toEqual(["--email", "--name"]);
103
- });
104
- test("uses description from schema", () => {
105
- const flags = generateBodyFlags({
106
- type: "object",
107
- properties: {
108
- email: { type: "string", description: "User email address" },
109
- },
110
- }, new Set());
111
- expect(flags[0]?.description).toBe("User email address");
112
- });
113
- });
114
- describe("parseDotNotationFlags", () => {
115
- test("parses flat flags", () => {
116
- const flagDefs = generateBodyFlags({
117
- type: "object",
118
- properties: {
119
- name: { type: "string" },
120
- age: { type: "integer" },
121
- },
122
- }, new Set());
123
- const result = parseDotNotationFlags({ name: "Ada", age: "30" }, flagDefs);
124
- expect(result).toEqual({
125
- name: "Ada",
126
- age: 30,
127
- });
128
- });
129
- test("parses nested flags into objects", () => {
130
- const flagDefs = generateBodyFlags({
131
- type: "object",
132
- properties: {
133
- name: { type: "string" },
134
- address: {
135
- type: "object",
136
- properties: {
137
- street: { type: "string" },
138
- city: { type: "string" },
139
- },
140
- },
141
- },
142
- }, new Set());
143
- // Commander keeps dots: --address.street -> "address.street"
144
- const result = parseDotNotationFlags({
145
- name: "Ada",
146
- "address.street": "123 Main",
147
- "address.city": "NYC",
148
- }, flagDefs);
149
- expect(result).toEqual({
150
- name: "Ada",
151
- address: {
152
- street: "123 Main",
153
- city: "NYC",
154
- },
155
- });
156
- });
157
- test("handles boolean flags", () => {
158
- const flagDefs = generateBodyFlags({
159
- type: "object",
160
- properties: {
161
- active: { type: "boolean" },
162
- },
163
- }, new Set());
164
- const result = parseDotNotationFlags({ active: true }, flagDefs);
165
- expect(result).toEqual({ active: true });
166
- });
167
- });
168
- describe("findMissingRequired", () => {
169
- test("finds missing required fields", () => {
170
- const flagDefs = generateBodyFlags({
171
- type: "object",
172
- properties: {
173
- name: { type: "string" },
174
- email: { type: "string" },
175
- },
176
- required: ["name", "email"],
177
- }, new Set());
178
- const missing = findMissingRequired({ name: "Ada" }, flagDefs);
179
- expect(missing).toEqual(["email"]);
180
- });
181
- test("returns empty when all required fields present", () => {
182
- const flagDefs = generateBodyFlags({
183
- type: "object",
184
- properties: {
185
- name: { type: "string" },
186
- },
187
- required: ["name"],
188
- }, new Set());
189
- const missing = findMissingRequired({ name: "Ada" }, flagDefs);
190
- expect(missing).toEqual([]);
191
- });
192
- });
@@ -1 +0,0 @@
1
- export {};
@@ -1,332 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { tmpdir } from "node:os";
3
- import { generateBodyFlags } from "./body-flags.js";
4
- import { buildRequest } from "./request.js";
5
- import { createAjv, formatAjvErrors } from "./validate/index.js";
6
- function makeAction(partial) {
7
- return {
8
- id: "test",
9
- key: "POST /contacts",
10
- action: "create",
11
- pathArgs: [],
12
- method: "POST",
13
- path: "/contacts",
14
- tags: [],
15
- style: "rest",
16
- positionals: [],
17
- flags: [],
18
- params: [],
19
- auth: { alternatives: [] },
20
- requestBody: {
21
- required: true,
22
- content: [
23
- {
24
- contentType: "application/json",
25
- required: true,
26
- schemaType: "object",
27
- },
28
- ],
29
- hasJson: true,
30
- hasFormUrlEncoded: false,
31
- hasMultipart: false,
32
- bodyFlags: ["--data", "--file"],
33
- preferredContentType: "application/json",
34
- preferredSchema: undefined,
35
- },
36
- requestBodySchema: {
37
- type: "object",
38
- properties: {
39
- name: { type: "string" },
40
- },
41
- required: ["name"],
42
- },
43
- ...partial,
44
- };
45
- }
46
- describe("buildRequest (requestBody)", () => {
47
- test("builds body from expanded body flags", async () => {
48
- const prevHome = process.env.HOME;
49
- const home = `${tmpdir()}/specli-test-${crypto.randomUUID()}`;
50
- process.env.HOME = home;
51
- try {
52
- const action = makeAction();
53
- const bodyFlagDefs = generateBodyFlags(action.requestBodySchema, new Set());
54
- const { request, curl } = await buildRequest({
55
- specId: "spec",
56
- action,
57
- positionalValues: [],
58
- flagValues: { name: "A" }, // --name A
59
- globals: {},
60
- servers: [
61
- { url: "https://api.example.com", variables: [], variableNames: [] },
62
- ],
63
- authSchemes: [],
64
- bodyFlagDefs,
65
- });
66
- expect(request.headers.get("Content-Type")).toBe("application/json");
67
- expect(await request.clone().text()).toBe('{"name":"A"}');
68
- expect(curl).toContain("--data");
69
- expect(curl).toContain('{"name":"A"}');
70
- }
71
- finally {
72
- process.env.HOME = prevHome;
73
- }
74
- });
75
- test("throws when requestBody is required but missing", async () => {
76
- const prevHome = process.env.HOME;
77
- const home = `${tmpdir()}/specli-test-${crypto.randomUUID()}`;
78
- process.env.HOME = home;
79
- try {
80
- const action = makeAction();
81
- const bodyFlagDefs = generateBodyFlags(action.requestBodySchema, new Set());
82
- await expect(() => buildRequest({
83
- specId: "spec",
84
- action,
85
- positionalValues: [],
86
- flagValues: {},
87
- globals: {},
88
- servers: [
89
- {
90
- url: "https://api.example.com",
91
- variables: [],
92
- variableNames: [],
93
- },
94
- ],
95
- authSchemes: [],
96
- bodyFlagDefs,
97
- })).toThrow("Required: --name");
98
- }
99
- finally {
100
- process.env.HOME = prevHome;
101
- }
102
- });
103
- test("throws friendly error for missing required expanded field", async () => {
104
- const prevHome = process.env.HOME;
105
- const home = `${tmpdir()}/specli-test-${crypto.randomUUID()}`;
106
- process.env.HOME = home;
107
- try {
108
- // Schema with two fields, one required
109
- const action = makeAction({
110
- requestBodySchema: {
111
- type: "object",
112
- properties: {
113
- name: { type: "string" },
114
- email: { type: "string" },
115
- },
116
- required: ["name"],
117
- },
118
- });
119
- const bodyFlagDefs = generateBodyFlags(action.requestBodySchema, new Set());
120
- // Provide email but not name (the required one)
121
- await expect(() => buildRequest({
122
- specId: "spec",
123
- action,
124
- positionalValues: [],
125
- flagValues: { email: "test@example.com" }, // --email (but missing --name)
126
- globals: {},
127
- servers: [
128
- {
129
- url: "https://api.example.com",
130
- variables: [],
131
- variableNames: [],
132
- },
133
- ],
134
- authSchemes: [],
135
- bodyFlagDefs,
136
- })).toThrow("Missing required fields: --name");
137
- }
138
- finally {
139
- process.env.HOME = prevHome;
140
- }
141
- });
142
- test("builds nested object from dot notation flags", async () => {
143
- const prevHome = process.env.HOME;
144
- const home = `${tmpdir()}/specli-test-${crypto.randomUUID()}`;
145
- process.env.HOME = home;
146
- try {
147
- const action = makeAction({
148
- requestBodySchema: {
149
- type: "object",
150
- properties: {
151
- name: { type: "string" },
152
- address: {
153
- type: "object",
154
- properties: {
155
- street: { type: "string" },
156
- city: { type: "string" },
157
- },
158
- },
159
- },
160
- required: ["name"],
161
- },
162
- });
163
- const bodyFlagDefs = generateBodyFlags(action.requestBodySchema, new Set());
164
- // Dot notation: --address.street and --address.city should create nested object
165
- const { request } = await buildRequest({
166
- specId: "spec",
167
- action,
168
- positionalValues: [],
169
- flagValues: {
170
- name: "Ada",
171
- "address.street": "123 Main St", // Commander keeps dots in keys
172
- "address.city": "NYC",
173
- },
174
- globals: {},
175
- servers: [
176
- { url: "https://api.example.com", variables: [], variableNames: [] },
177
- ],
178
- authSchemes: [],
179
- bodyFlagDefs,
180
- });
181
- const body = JSON.parse(await request.clone().text());
182
- expect(body).toEqual({
183
- name: "Ada",
184
- address: {
185
- street: "123 Main St",
186
- city: "NYC",
187
- },
188
- });
189
- }
190
- finally {
191
- process.env.HOME = prevHome;
192
- }
193
- });
194
- });
195
- describe("buildRequest (query parameters)", () => {
196
- test("builds query string from flag values", async () => {
197
- const prevHome = process.env.HOME;
198
- const home = `${tmpdir()}/specli-test-${crypto.randomUUID()}`;
199
- process.env.HOME = home;
200
- try {
201
- const action = {
202
- id: "test",
203
- key: "GET /contacts",
204
- action: "list",
205
- pathArgs: [],
206
- method: "GET",
207
- path: "/contacts",
208
- tags: [],
209
- style: "rest",
210
- positionals: [],
211
- flags: [
212
- {
213
- flag: "--limit",
214
- name: "limit",
215
- in: "query",
216
- type: "integer",
217
- required: false,
218
- },
219
- {
220
- flag: "--name",
221
- name: "name",
222
- in: "query",
223
- type: "string",
224
- required: false,
225
- },
226
- ],
227
- params: [
228
- {
229
- kind: "flag",
230
- flag: "--limit",
231
- name: "limit",
232
- in: "query",
233
- required: false,
234
- type: "integer",
235
- },
236
- {
237
- kind: "flag",
238
- flag: "--name",
239
- name: "name",
240
- in: "query",
241
- required: false,
242
- type: "string",
243
- },
244
- ],
245
- auth: { alternatives: [] },
246
- };
247
- const { request } = await buildRequest({
248
- specId: "spec",
249
- action,
250
- positionalValues: [],
251
- flagValues: { limit: 10, name: "andrew" },
252
- globals: {},
253
- servers: [
254
- { url: "https://api.example.com", variables: [], variableNames: [] },
255
- ],
256
- authSchemes: [],
257
- });
258
- expect(request.method).toBe("GET");
259
- expect(request.url).toBe("https://api.example.com/contacts?limit=10&name=andrew");
260
- }
261
- finally {
262
- process.env.HOME = prevHome;
263
- }
264
- });
265
- test("handles array query parameters", async () => {
266
- const prevHome = process.env.HOME;
267
- const home = `${tmpdir()}/specli-test-${crypto.randomUUID()}`;
268
- process.env.HOME = home;
269
- try {
270
- const action = {
271
- id: "test",
272
- key: "GET /contacts",
273
- action: "list",
274
- pathArgs: [],
275
- method: "GET",
276
- path: "/contacts",
277
- tags: [],
278
- style: "rest",
279
- positionals: [],
280
- flags: [
281
- {
282
- flag: "--tag",
283
- name: "tag",
284
- in: "query",
285
- type: "array",
286
- itemType: "string",
287
- required: false,
288
- },
289
- ],
290
- params: [
291
- {
292
- kind: "flag",
293
- flag: "--tag",
294
- name: "tag",
295
- in: "query",
296
- required: false,
297
- type: "array",
298
- },
299
- ],
300
- auth: { alternatives: [] },
301
- };
302
- const { request } = await buildRequest({
303
- specId: "spec",
304
- action,
305
- positionalValues: [],
306
- flagValues: { tag: ["vip", "active"] },
307
- globals: {},
308
- servers: [
309
- { url: "https://api.example.com", variables: [], variableNames: [] },
310
- ],
311
- authSchemes: [],
312
- });
313
- expect(request.url).toBe("https://api.example.com/contacts?tag=vip&tag=active");
314
- }
315
- finally {
316
- process.env.HOME = prevHome;
317
- }
318
- });
319
- });
320
- describe("formatAjvErrors", () => {
321
- test("pretty prints required errors", () => {
322
- const ajv = createAjv();
323
- const validate = ajv.compile({
324
- type: "object",
325
- properties: { name: { type: "string" } },
326
- required: ["name"],
327
- });
328
- validate({});
329
- const msg = formatAjvErrors(validate.errors);
330
- expect(msg).toBe("/ missing required property 'name'");
331
- });
332
- });
@@ -1 +0,0 @@
1
- export {};
@@ -1,75 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { coerceArrayInput, coerceValue } from "./coerce.js";
3
- describe("coerceValue", () => {
4
- test("returns string as-is for string type", () => {
5
- expect(coerceValue("hello", "string")).toBe("hello");
6
- });
7
- test("returns string as-is for unknown type", () => {
8
- expect(coerceValue("hello", "unknown")).toBe("hello");
9
- });
10
- test("parses integer type", () => {
11
- expect(coerceValue("42", "integer")).toBe(42);
12
- expect(coerceValue("-10", "integer")).toBe(-10);
13
- expect(coerceValue("0", "integer")).toBe(0);
14
- });
15
- test("throws for invalid integer", () => {
16
- expect(() => coerceValue("abc", "integer")).toThrow("Expected integer");
17
- });
18
- test("truncates decimal for integer type (parseInt behavior)", () => {
19
- // parseInt("12.5", 10) returns 12 - this is expected JS behavior
20
- expect(coerceValue("12.5", "integer")).toBe(12);
21
- });
22
- test("parses number type", () => {
23
- expect(coerceValue("42", "number")).toBe(42);
24
- expect(coerceValue("3.14", "number")).toBe(3.14);
25
- expect(coerceValue("-0.5", "number")).toBe(-0.5);
26
- });
27
- test("throws for invalid number", () => {
28
- expect(() => coerceValue("abc", "number")).toThrow("Expected number");
29
- });
30
- test("parses boolean type", () => {
31
- expect(coerceValue("true", "boolean")).toBe(true);
32
- expect(coerceValue("false", "boolean")).toBe(false);
33
- });
34
- test("throws for invalid boolean", () => {
35
- expect(() => coerceValue("yes", "boolean")).toThrow("Expected boolean");
36
- expect(() => coerceValue("1", "boolean")).toThrow("Expected boolean");
37
- });
38
- test("parses object type as JSON", () => {
39
- expect(coerceValue('{"a":1}', "object")).toEqual({ a: 1 });
40
- });
41
- test("throws for invalid object JSON", () => {
42
- expect(() => coerceValue("not json", "object")).toThrow("Expected JSON object");
43
- });
44
- });
45
- describe("coerceArrayInput", () => {
46
- test("parses comma-separated values", () => {
47
- expect(coerceArrayInput("a,b,c", "string")).toEqual(["a", "b", "c"]);
48
- });
49
- test("trims whitespace in comma-separated values", () => {
50
- expect(coerceArrayInput("a, b, c", "string")).toEqual(["a", "b", "c"]);
51
- });
52
- test("parses JSON array", () => {
53
- expect(coerceArrayInput('["a","b","c"]', "string")).toEqual([
54
- "a",
55
- "b",
56
- "c",
57
- ]);
58
- });
59
- test("coerces array items to specified type", () => {
60
- expect(coerceArrayInput("1,2,3", "integer")).toEqual([1, 2, 3]);
61
- expect(coerceArrayInput('["1","2","3"]', "integer")).toEqual([1, 2, 3]);
62
- });
63
- test("returns empty array for empty string", () => {
64
- expect(coerceArrayInput("", "string")).toEqual([]);
65
- expect(coerceArrayInput(" ", "string")).toEqual([]);
66
- });
67
- test("throws for invalid JSON array", () => {
68
- expect(() => coerceArrayInput("[invalid", "string")).toThrow("Expected JSON array");
69
- });
70
- test("treats non-array JSON as comma-separated string", () => {
71
- // If it doesn't start with '[', it's treated as comma-separated
72
- // '{"a":1}' doesn't start with '[', so it's treated as a single value
73
- expect(coerceArrayInput('{"a":1}', "string")).toEqual(['{"a":1}']);
74
- });
75
- });