Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
5 changes: 5 additions & 0 deletions .changeset/api-field-expand-completion.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@tailor-platform/sdk": patch
---

Make `tailor-sdk api --field` tab completion faster by pre-enumerating candidates into the generated shell script. Field names, enum values, and `true`/`false` for bool fields are now resolved from a static lookup table at TAB time instead of spawning a Node process per keystroke.
5 changes: 5 additions & 0 deletions .changeset/api-field-option.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@tailor-platform/sdk": minor
---

Add `--field key=value` (`-f`) to `tailor-sdk api <endpoint>` for setting request-body fields without writing JSON. Dotted keys build nested objects (`-f application.name=foo`), `--field` overrides matching keys in `--body`, and field names tab-complete from the endpoint's proto schema (bash / zsh / fish) — including step-by-step completion of nested message fields.
21 changes: 15 additions & 6 deletions packages/sdk/docs/cli/application.md
Original file line number Diff line number Diff line change
Expand Up @@ -336,12 +336,13 @@ tailor-sdk api [options] [command] <endpoint>

**Options**

| Option | Alias | Description | Required | Default | Env |
| ------------------------------- | ----- | ----------------------- | -------- | -------------------- | --------------------------------- |
| `--workspace-id <WORKSPACE_ID>` | `-w` | Workspace ID | No | - | `TAILOR_PLATFORM_WORKSPACE_ID` |
| `--profile <PROFILE>` | `-p` | Workspace profile | No | - | `TAILOR_PLATFORM_PROFILE` |
| `--config <CONFIG>` | `-c` | Path to SDK config file | No | `"tailor.config.ts"` | `TAILOR_PLATFORM_SDK_CONFIG_PATH` |
| `--body <BODY>` | `-b` | Request body as JSON. | No | `"{}"` | - |
| Option | Alias | Description | Required | Default | Env |
| ------------------------------- | ----- | --------------------------------------------------------------------------------- | -------- | -------------------- | --------------------------------- |
| `--workspace-id <WORKSPACE_ID>` | `-w` | Workspace ID | No | - | `TAILOR_PLATFORM_WORKSPACE_ID` |
| `--profile <PROFILE>` | `-p` | Workspace profile | No | - | `TAILOR_PLATFORM_PROFILE` |
| `--config <CONFIG>` | `-c` | Path to SDK config file | No | `"tailor.config.ts"` | `TAILOR_PLATFORM_SDK_CONFIG_PATH` |
| `--body <BODY>` | `-b` | Request body as JSON. | No | `"{}"` | - |
| `--field <FIELD>` | `-f` | Set a body field as `key=value` (repeatable; dotted keys nest). Overrides --body. | No | - | - |

<!-- politty:command:api:options:end -->

Expand All @@ -361,6 +362,12 @@ See [Global Options](../cli-reference.md#global-options) for options available t
$ tailor-sdk api GetApplication -b '{"applicationName":"app-1"}'
```

**Same as above, using --field instead of --body.**

```bash
$ tailor-sdk api GetApplication -f applicationName=app-1
```

**List all invocable OperatorService methods.**

```bash
Expand Down Expand Up @@ -390,6 +397,8 @@ The request body is inferred from the proto definition of the target endpoint, a

Values already present in `--body` are never overridden. If a value cannot be resolved (e.g. no config found), injection is silently skipped and the server-side validation error takes precedence.

Use `--field key=value` (repeatable) to set request body fields without writing JSON. Dotted keys (e.g. `application.name=foo`) build nested objects. `--field` overrides matching fields in `--body` and tab-completes from the endpoint's proto schema.

<!-- politty:command:api:notes:end -->
<!-- politty:command:api inspect:heading:start -->

Expand Down
153 changes: 153 additions & 0 deletions packages/sdk/src/cli/commands/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,4 +238,157 @@ describe("api command body auto-injection", () => {
expect(loadWorkspaceId).not.toHaveBeenCalled();
});
});

describe("--field option", () => {
test("should set a flat field into the body", async () => {
vi.mocked(loadWorkspaceId).mockResolvedValue("ws-1");

await runCommand(apiCommand, ["GetFunctionExecution", "-f", "executionId=exec-1"]);

const [, options] = fetchMock.mock.calls[0] as [string, RequestInit];
const body = JSON.parse(options.body as string);
expect(body.executionId).toBe("exec-1");
expect(body.workspaceId).toBe("ws-1");
});

test("should set nested fields via dotted keys", async () => {
await runCommand(apiCommand, [
"GetFunctionExecution",
"-f",
"a.b.c=hello",
"-f",
"a.b.d=world",
]);

const [, options] = fetchMock.mock.calls[0] as [string, RequestInit];
const body = JSON.parse(options.body as string);
expect(body.a).toEqual({ b: { c: "hello", d: "world" } });
});

test("should let --field override matching keys in --body", async () => {
await runCommand(apiCommand, [
"GetFunctionExecution",
"-b",
'{"executionId":"from-body"}',
"-f",
"executionId=from-field",
]);

const [, options] = fetchMock.mock.calls[0] as [string, RequestInit];
const body = JSON.parse(options.body as string);
expect(body.executionId).toBe("from-field");
});

test("should destructively overwrite a non-object body value with a nested --field", async () => {
await runCommand(apiCommand, ["GetFunctionExecution", "-b", '{"a":"str"}', "-f", "a.b=baz"]);

const [, options] = fetchMock.mock.calls[0] as [string, RequestInit];
const body = JSON.parse(options.body as string);
expect(body.a).toEqual({ b: "baz" });
});

test("should skip workspaceId auto-injection when supplied via --field", async () => {
await runCommand(apiCommand, [
"GetFunctionExecution",
"-f",
"workspaceId=ws-x",
"-f",
"executionId=exec-1",
]);

expect(loadWorkspaceId).not.toHaveBeenCalled();
const [, options] = fetchMock.mock.calls[0] as [string, RequestInit];
const body = JSON.parse(options.body as string);
expect(body.workspaceId).toBe("ws-x");
});

test("should error when --field is combined with a non-object --body", async () => {
const result = await runCommand(apiCommand, [
"GetFunctionExecution",
"-b",
'"just-a-string"',
"-f",
"executionId=exec-1",
]);

expect(result.success).toBe(false);
expect(fetchMock).not.toHaveBeenCalled();
});

test("should reject malformed --field values", async () => {
const result = await runCommand(apiCommand, ["GetFunctionExecution", "-f", "no-equals"]);

expect(result.success).toBe(false);
expect(fetchMock).not.toHaveBeenCalled();
});

test("should reject empty dotted segments in --field key", async () => {
const result = await runCommand(apiCommand, ["GetFunctionExecution", "-f", "a..b=x"]);

expect(result.success).toBe(false);
expect(fetchMock).not.toHaveBeenCalled();
});

test("should coerce true/false to booleans for bool-typed fields", async () => {
vi.mocked(loadWorkspaceId).mockResolvedValue("ws-1");

await runCommand(apiCommand, [
"CreateWorkspace",
"-f",
"name=ws",
"-f",
"deleteProtection=true",
]);

const [, options] = fetchMock.mock.calls[0] as [string, RequestInit];
const body = JSON.parse(options.body as string);
expect(body.deleteProtection).toBe(true);
expect(typeof body.deleteProtection).toBe("boolean");
});

test("should reject non-boolean values for bool-typed fields", async () => {
vi.mocked(loadWorkspaceId).mockResolvedValue("ws-1");

const result = await runCommand(apiCommand, [
"CreateWorkspace",
"-f",
"name=ws",
"-f",
"deleteProtection=yes",
]);

expect(result.success).toBe(false);
expect(fetchMock).not.toHaveBeenCalled();
});

test("should leave string scalars unchanged", async () => {
vi.mocked(loadWorkspaceId).mockResolvedValue("ws-1");

await runCommand(apiCommand, ["GetFunctionExecution", "-f", "executionId=exec-1"]);

const [, options] = fetchMock.mock.calls[0] as [string, RequestInit];
const body = JSON.parse(options.body as string);
expect(body.executionId).toBe("exec-1");
expect(typeof body.executionId).toBe("string");
});

test("should reject prototype-pollution segments in --field key", async () => {
// Without this guard, `cursor[key]` would resolve `__proto__` against
// Object.prototype, letting the assignment mutate the global prototype
// instead of the body.
const polluted: { polluted?: unknown } = {};
const before = polluted.polluted;

const result = await runCommand(apiCommand, [
"GetFunctionExecution",
"-f",
"__proto__.polluted=yes",
]);

expect(result.success).toBe(false);
expect(fetchMock).not.toHaveBeenCalled();
expect(polluted.polluted).toBe(before);
expect(({} as { polluted?: unknown }).polluted).toBeUndefined();
});
});
});
128 changes: 125 additions & 3 deletions packages/sdk/src/cli/commands/api/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { ScalarType } from "@bufbuild/protobuf";
import { arg } from "politty";
import { z } from "zod";
import { configArg, workspaceArgs } from "@/cli/shared/args";
Expand All @@ -8,8 +9,15 @@ import { logger } from "@/cli/shared/logger";
import { apiCall } from "./api-call";
import { inspectCommand } from "./inspect";
import { listCommand } from "./list";
import { extractMethodName, getMethodDescriptor, listMethodNames } from "./proto-reflect";
import {
enumerateAllFieldCompletions,
extractMethodName,
getMethodDescriptor,
listMethodChoices,
resolveLeafField,
} from "./proto-reflect";
import type { LoadedConfig } from "@/cli/shared/config-loader";
import type { DescField } from "@bufbuild/protobuf";

export { apiCall, type ApiCallOptions, type ApiCallResult } from "./api-call";

Expand Down Expand Up @@ -54,6 +62,89 @@ function parseBodyAsObject(body: string): Record<string, unknown> | undefined {
return parsed as Record<string, unknown>;
}

/**
* Set a dotted path on a body object, replacing non-object intermediates as
* needed. `--field` takes precedence over `--body`, so collisions overwrite.
* @param obj - The body object to mutate
* @param path - Dot-split path segments (e.g. ["application", "name"])
* @param value - Value to assign at the leaf
*/
function setNestedPath(obj: Record<string, unknown>, path: string[], value: unknown): void {
let cursor: Record<string, unknown> = obj;
for (let i = 0; i < path.length - 1; i++) {
const key = path[i];
const next = cursor[key];
if (typeof next !== "object" || next === null || Array.isArray(next)) {
cursor[key] = {};
}
cursor = cursor[key] as Record<string, unknown>;
}
cursor[path[path.length - 1]] = value;
}

/**
* Coerces a raw `--field` string value to match the leaf proto type so the
* JSON body sends a properly typed value. Today we only coerce bool — the
* completion candidates explicitly suggest `=true`/`=false`, so sending the
* literal string "true" would be a visible mismatch. Other scalars are left
* as strings: proto JSON accepts string forms for ints/floats/int64/etc.,
* and we'd rather pass through what the user typed than silently coerce.
* @param field - The resolved leaf field descriptor, or undefined when the path didn't resolve
* @param raw - The raw string value after `=`
* @returns The value to write into the body object
*/
function coerceFieldValue(field: DescField | undefined, raw: string): unknown {
if (field && field.fieldKind === "scalar" && field.scalar === ScalarType.BOOL) {
if (raw === "true") return true;
if (raw === "false") return false;
throw new Error(`Invalid value for bool field: '${raw}'. Expected 'true' or 'false'.`);
}
return raw;
}

interface ParsedField {
path: string[];
value: string;
}

// Prototype-pollution sinks: `setNestedPath` walks `cursor[key]`, so a
// segment that resolves on `Object.prototype` (e.g. `__proto__`) would let an
// untrusted dotted key mutate the runtime prototype instead of the body.
const FORBIDDEN_SEGMENTS = new Set(["__proto__", "constructor", "prototype"]);

const fieldArg = z.string().transform((val, ctx): ParsedField => {
const eq = val.indexOf("=");
if (eq < 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Invalid field format: '${val}'. Expected format: 'key=value' or 'a.b.c=value'`,
});
return z.NEVER;
}
const key = val.slice(0, eq);
if (key.length === 0) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Field key cannot be empty" });
return z.NEVER;
}
const segments = key.split(".");
if (segments.some((seg) => seg.length === 0)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Invalid field key: '${key}'. Dotted segments cannot be empty`,
});
return z.NEVER;
}
const forbidden = segments.find((seg) => FORBIDDEN_SEGMENTS.has(seg));
if (forbidden) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Invalid field key: '${key}'. Segment '${forbidden}' is not allowed.`,
});
return z.NEVER;
}
return { path: segments, value: val.slice(eq + 1) };
});

export const apiCommand = defineAppCommand({
name: "api",
description: "Call Tailor Platform API endpoints directly.",
Expand All @@ -66,12 +157,18 @@ The request body is inferred from the proto definition of the target endpoint, a
- Auth / Tenant / UserProfile endpoints use \`auth.name\`.
- IdP / TailorDB / Pipeline endpoints use the sole configured namespace when exactly one is defined.

Values already present in \`--body\` are never overridden. If a value cannot be resolved (e.g. no config found), injection is silently skipped and the server-side validation error takes precedence.`,
Values already present in \`--body\` are never overridden. If a value cannot be resolved (e.g. no config found), injection is silently skipped and the server-side validation error takes precedence.

Use \`--field key=value\` (repeatable) to set request body fields without writing JSON. Dotted keys (e.g. \`application.name=foo\`) build nested objects. \`--field\` overrides matching fields in \`--body\` and tab-completes from the endpoint's proto schema.`,
examples: [
{
cmd: 'GetApplication -b \'{"applicationName":"app-1"}\'',
desc: "Call an endpoint; workspaceId is auto-injected.",
},
{
cmd: "GetApplication -f applicationName=app-1",
desc: "Same as above, using --field instead of --body.",
},
{
cmd: "list",
desc: "List all invocable OperatorService methods.",
Expand All @@ -93,11 +190,25 @@ Values already present in \`--body\` are never overridden. If a value cannot be
alias: "b",
description: "Request body as JSON.",
}),
field: arg(fieldArg.array().optional(), {
alias: "f",
description:
"Set a body field as `key=value` (repeatable; dotted keys nest). Overrides --body.",
completion: {
custom: {
expand: {
dependsOn: ["endpoint"],
enumerate: ({ endpoint }) =>
enumerateAllFieldCompletions(extractMethodName(endpoint ?? "")),
},
},
},
}),
endpoint: arg(z.string(), {
positional: true,
description:
"API endpoint to call (e.g., 'GetApplication' or 'tailor.v1.OperatorService/GetApplication').",
completion: { custom: { choices: listMethodNames() } },
completion: { custom: { choices: listMethodChoices() } },
}),
})
.strict(),
Expand All @@ -108,6 +219,17 @@ Values already present in \`--body\` are never overridden. If a value cannot be
const parsedBody = parseBodyAsObject(args.body);
let mutated = false;

if (args.field && args.field.length > 0) {
if (!parsedBody) {
throw new Error("--field requires --body to be a JSON object (or omitted).");
}
for (const f of args.field) {
const leaf = method ? resolveLeafField(method.input, f.path) : undefined;
setNestedPath(parsedBody, f.path, coerceFieldValue(leaf, f.value));
}
mutated = true;
}

if (parsedBody && method) {
// Use localName so the presence check matches the keys --body parsing
// writes into the request body.
Expand Down
Loading
Loading