diff --git a/.changeset/api-field-expand-completion.md b/.changeset/api-field-expand-completion.md new file mode 100644 index 000000000..0a716b88a --- /dev/null +++ b/.changeset/api-field-expand-completion.md @@ -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. diff --git a/.changeset/api-field-option.md b/.changeset/api-field-option.md new file mode 100644 index 000000000..3f4f60508 --- /dev/null +++ b/.changeset/api-field-option.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk": minor +--- + +Add `--field key=value` (`-f`) to `tailor-sdk api ` 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. diff --git a/packages/sdk/docs/cli/application.md b/packages/sdk/docs/cli/application.md index 42d2bf1a0..89bf35275 100644 --- a/packages/sdk/docs/cli/application.md +++ b/packages/sdk/docs/cli/application.md @@ -336,12 +336,13 @@ tailor-sdk api [options] [command] **Options** -| Option | Alias | Description | Required | Default | Env | -| ------------------------------- | ----- | ----------------------- | -------- | -------------------- | --------------------------------- | -| `--workspace-id ` | `-w` | Workspace ID | No | - | `TAILOR_PLATFORM_WORKSPACE_ID` | -| `--profile ` | `-p` | Workspace profile | No | - | `TAILOR_PLATFORM_PROFILE` | -| `--config ` | `-c` | Path to SDK config file | No | `"tailor.config.ts"` | `TAILOR_PLATFORM_SDK_CONFIG_PATH` | -| `--body ` | `-b` | Request body as JSON. | No | `"{}"` | - | +| Option | Alias | Description | Required | Default | Env | +| ------------------------------- | ----- | --------------------------------------------------------------------------------- | -------- | -------------------- | --------------------------------- | +| `--workspace-id ` | `-w` | Workspace ID | No | - | `TAILOR_PLATFORM_WORKSPACE_ID` | +| `--profile ` | `-p` | Workspace profile | No | - | `TAILOR_PLATFORM_PROFILE` | +| `--config ` | `-c` | Path to SDK config file | No | `"tailor.config.ts"` | `TAILOR_PLATFORM_SDK_CONFIG_PATH` | +| `--body ` | `-b` | Request body as JSON. | No | `"{}"` | - | +| `--field ` | `-f` | Set a body field as `key=value` (repeatable; dotted keys nest). Overrides --body. | No | - | - | @@ -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 @@ -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. + diff --git a/packages/sdk/src/cli/commands/api.test.ts b/packages/sdk/src/cli/commands/api.test.ts index 26894477b..41e277eb4 100644 --- a/packages/sdk/src/cli/commands/api.test.ts +++ b/packages/sdk/src/cli/commands/api.test.ts @@ -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(); + }); + }); }); diff --git a/packages/sdk/src/cli/commands/api/index.ts b/packages/sdk/src/cli/commands/api/index.ts index 6a9bb54ab..5b61910af 100644 --- a/packages/sdk/src/cli/commands/api/index.ts +++ b/packages/sdk/src/cli/commands/api/index.ts @@ -1,3 +1,4 @@ +import { ScalarType } from "@bufbuild/protobuf"; import { arg } from "politty"; import { z } from "zod"; import { configArg, workspaceArgs } from "@/cli/shared/args"; @@ -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"; @@ -54,6 +62,89 @@ function parseBodyAsObject(body: string): Record | undefined { return parsed as Record; } +/** + * 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, path: string[], value: unknown): void { + let cursor: Record = 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; + } + 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.", @@ -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.", @@ -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(), @@ -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. diff --git a/packages/sdk/src/cli/commands/api/proto-reflect.test.ts b/packages/sdk/src/cli/commands/api/proto-reflect.test.ts index 300f873af..f3d7c5481 100644 --- a/packages/sdk/src/cli/commands/api/proto-reflect.test.ts +++ b/packages/sdk/src/cli/commands/api/proto-reflect.test.ts @@ -1,5 +1,12 @@ +import { ScalarType } from "@bufbuild/protobuf"; import { describe, expect, test } from "vitest"; -import { getMethodDescriptor, listMethodNames } from "./proto-reflect"; +import { + enumerateAllFieldCompletions, + getMethodDescriptor, + listMethodChoices, + listMethodNames, + resolveLeafField, +} from "./proto-reflect"; describe("listMethodNames", () => { test("returns sorted method names including known unary methods", () => { @@ -22,6 +29,16 @@ describe("listMethodNames", () => { }); }); +describe("listMethodChoices", () => { + test("includes both bare and fully-qualified method names", () => { + const choices = listMethodChoices(); + expect(choices).toContain("GetApplication"); + expect(choices).toContain("tailor.v1.OperatorService/GetApplication"); + const sorted = [...choices].sort(); + expect(choices).toEqual(sorted); + }); +}); + describe("getMethodDescriptor", () => { test("returns descriptor for known method", () => { const m = getMethodDescriptor("GetApplication"); @@ -33,3 +50,92 @@ describe("getMethodDescriptor", () => { expect(getMethodDescriptor("NotARealMethod")).toBeUndefined(); }); }); + +describe("enumerateAllFieldCompletions", () => { + test("emits `key=` for each scalar leaf", () => { + const values = enumerateAllFieldCompletions("GetFunctionExecution").map((c) => c.value); + expect(values).toContain("workspaceId="); + expect(values).toContain("executionId="); + }); + + test("emits enum values inline alongside the key for enum leaves", () => { + const values = enumerateAllFieldCompletions("ListWorkspaces").map((c) => c.value); + expect(values).toContain("pageDirection="); + expect(values).toContain("pageDirection=PAGE_DIRECTION_UNSPECIFIED"); + expect(values).toContain("pageDirection=PAGE_DIRECTION_ASC"); + expect(values).toContain("pageDirection=PAGE_DIRECTION_DESC"); + }); + + test("emits true/false inline for bool leaves", () => { + const values = enumerateAllFieldCompletions("CreateWorkspace").map((c) => c.value); + expect(values).toContain("deleteProtection="); + expect(values).toContain("deleteProtection=true"); + expect(values).toContain("deleteProtection=false"); + }); + + test("emits `key.` drill-down and recurses into nested messages", () => { + const values = enumerateAllFieldCompletions("CreateTailorDBType").map((c) => c.value); + expect(values).toContain("tailordbType."); + expect(values).toContain("tailordbType.name="); + }); + + test("returns an empty list for unknown methods", () => { + expect(enumerateAllFieldCompletions("NotARealMethod")).toEqual([]); + }); + + test("skips list and map fields (no dotted-path representation)", () => { + // CreateApplication has `subgraphs` (repeated message). Offering + // `subgraphs.` or `subgraphs.=…` would mislead callers into building + // an object where the proto expects an array, so they must not appear. + const values = enumerateAllFieldCompletions("CreateApplication").map((c) => c.value); + expect(values).not.toContain("subgraphs."); + expect(values.some((v) => v.startsWith("subgraphs."))).toBe(false); + }); + + test("treats google.protobuf well-known types as leaves, not nested objects", () => { + // UpdateWorkspace has `updateMask` (google.protobuf.FieldMask). proto JSON + // serializes it as a string ("field1,field2"), so drilling into its + // internal `paths` repeated field would build a body the server rejects. + const values = enumerateAllFieldCompletions("UpdateWorkspace").map((c) => c.value); + expect(values).toContain("updateMask="); + expect(values).not.toContain("updateMask."); + expect(values.some((v) => v.startsWith("updateMask."))).toBe(false); + }); + + test("omits google.protobuf.Struct and other unrepresentable well-known types", () => { + // TriggerExecutor.payload is google.protobuf.Struct. proto JSON requires + // an object value there, so neither `payload=` (sends a string) nor + // `payload.…` (wrong shape) is correct. Don't offer the field at all. + const values = enumerateAllFieldCompletions("TriggerExecutor").map((c) => c.value); + expect(values).not.toContain("payload="); + expect(values).not.toContain("payload."); + expect(values.some((v) => v === "payload=" || v.startsWith("payload."))).toBe(false); + }); +}); + +describe("resolveLeafField", () => { + test("resolves a top-level leaf", () => { + const m = getMethodDescriptor("CreateWorkspace"); + if (!m) throw new Error("CreateWorkspace missing"); + const field = resolveLeafField(m.input, ["deleteProtection"]); + expect(field?.localName).toBe("deleteProtection"); + expect(field?.fieldKind).toBe("scalar"); + if (field?.fieldKind === "scalar") { + expect(field.scalar).toBe(ScalarType.BOOL); + } + }); + + test("resolves a nested leaf through a singular message", () => { + const m = getMethodDescriptor("CreateTailorDBType"); + if (!m) throw new Error("CreateTailorDBType missing"); + const field = resolveLeafField(m.input, ["tailordbType", "name"]); + expect(field?.localName).toBe("name"); + }); + + test("returns undefined when the path doesn't exist", () => { + const m = getMethodDescriptor("CreateWorkspace"); + if (!m) throw new Error("CreateWorkspace missing"); + expect(resolveLeafField(m.input, ["nope"])).toBeUndefined(); + expect(resolveLeafField(m.input, ["deleteProtection", "extra"])).toBeUndefined(); + }); +}); diff --git a/packages/sdk/src/cli/commands/api/proto-reflect.ts b/packages/sdk/src/cli/commands/api/proto-reflect.ts index c9bc93b1f..828f5d6fd 100644 --- a/packages/sdk/src/cli/commands/api/proto-reflect.ts +++ b/packages/sdk/src/cli/commands/api/proto-reflect.ts @@ -1,24 +1,183 @@ +import { ScalarType } from "@bufbuild/protobuf"; import { OperatorService } from "@tailor-proto/tailor/v1/service_pb"; -import type { DescMethodUnary } from "@bufbuild/protobuf"; +import type { DescField, DescMessage, DescMethodUnary } from "@bufbuild/protobuf"; // `tailor-sdk api` issues a single JSON POST and reads one JSON response, so // only unary RPCs can be invoked. Streaming methods are filtered out of all -// discovery surfaces (`api list`, `api inspect`). -function unaryMethods(): DescMethodUnary[] { - return OperatorService.methods.filter((m): m is DescMethodUnary => m.methodKind === "unary"); -} +// discovery surfaces (`api list`, `api inspect`). `OperatorService.methods` +// is invariant at runtime, so we filter once and reuse — completion-script +// generation walks every method and would otherwise re-filter per call. +const UNARY_METHODS: DescMethodUnary[] = OperatorService.methods.filter( + (m): m is DescMethodUnary => m.methodKind === "unary", +); export function listMethodNames(): string[] { - return unaryMethods() - .map((m) => m.name) - .sort(); + return UNARY_METHODS.map((m) => m.name).sort(); +} + +/** + * Returns every accepted form of the `endpoint` positional — bare method name + * and fully-qualified `service/Method`. politty's expand completion keys its + * static table by the literal endpoint string, so both forms must appear in + * `choices` or the user typing the fully-qualified name gets no `--field` + * completion candidates back at TAB time. + * @returns Sorted list of accepted endpoint values + */ +export function listMethodChoices(): string[] { + return UNARY_METHODS.flatMap((m) => [m.name, `${OperatorService.typeName}/${m.name}`]).sort(); } export function getMethodDescriptor(methodName: string): DescMethodUnary | undefined { - return unaryMethods().find((m) => m.name === methodName); + return UNARY_METHODS.find((m) => m.name === methodName); } export function extractMethodName(endpoint: string): string { if (!endpoint.includes("/")) return endpoint; return endpoint.split("/").pop() ?? endpoint; } + +/** + * Returns the nested message descriptor when the field is a message, a list of + * messages, or a map with message values. Otherwise returns undefined. + * @param field - The proto field descriptor to inspect + * @returns The nested message descriptor, or undefined when the field isn't message-shaped + */ +export function nestedMessage(field: DescField): DescMessage | undefined { + if (field.fieldKind === "message") return field.message; + if (field.fieldKind === "list" && field.listKind === "message") return field.message; + if (field.fieldKind === "map" && field.mapKind === "message") return field.message; + return undefined; +} + +/** + * Most `google.protobuf.*` well-known types serialize as scalars in proto JSON + * (Timestamp/Duration → RFC3339/duration string, FieldMask → comma-joined + * string, *Value wrappers → underlying primitive, etc.), so we must not let + * `--field` drill into their internal fields. Treating them as leaves keeps + * the completion and the body shape consistent with what the server expects. + * @param message - The proto message descriptor to inspect + * @returns True when the message is a well-known type + */ +function isWellKnownType(message: DescMessage): boolean { + return message.typeName.startsWith("google.protobuf."); +} + +// Well-known types whose proto JSON shape is an object/array/null/arbitrary +// value — they cannot be expressed by a single `--field key=value` string. +// Drill-down would build the wrong shape, and a flat `key=foo` would send +// "foo" where the server expects e.g. an object; we skip them entirely from +// completion and from leaf resolution. +const UNREPRESENTABLE_WELL_KNOWN_TYPES = new Set([ + "google.protobuf.Struct", + "google.protobuf.Value", + "google.protobuf.ListValue", + "google.protobuf.NullValue", + "google.protobuf.Any", + "google.protobuf.Empty", +]); + +/** + * @param message - The proto message descriptor to inspect + * @returns True when the message is a well-known type that `--field` cannot represent + */ +function isUnrepresentableWellKnownType(message: DescMessage): boolean { + return UNREPRESENTABLE_WELL_KNOWN_TYPES.has(message.typeName); +} + +export interface FieldCompletionCandidate { + value: string; + description: string; +} + +/** + * Pre-enumerates every `--field` completion candidate for `methodName`, + * walking the input message tree to produce a flat list that the shell can + * prefix-filter at TAB time. Used by the `expand` completion variant so no + * Node process is spawned per keystroke. + * + * For each leaf field, emits `key=` (key completion). Enum leaves additionally + * emit `key=ENUM_VALUE` per value, bool leaves emit `key=true` and + * `key=false`. For nested messages, emits `key.` (drill-down) and recurses. + * + * Returns an empty array when `methodName` is unknown so the `expand` + * generator stays exception-free at script-generation time. + * @param methodName - Name of the unary RPC whose input message is being walked + * @returns Flat list of `{ value, description }` candidates + */ +export function enumerateAllFieldCompletions(methodName: string): FieldCompletionCandidate[] { + const method = getMethodDescriptor(methodName); + if (!method) return []; + + const candidates: FieldCompletionCandidate[] = []; + const visited = new Set(); + + function walk(message: DescMessage, prefix: string): void { + visited.add(message); + for (const field of message.fields) { + // `--field` uses `key=value` with dotted keys building nested objects; + // it has no syntax for arrays or maps. Skip list/map fields so we don't + // tab-complete a path that `setNestedPath` would silently turn into the + // wrong shape (e.g. `subgraphs.name=x` → `{subgraphs:{name:"x"}}` when + // the proto expects a repeated message). + if (field.fieldKind === "list" || field.fieldKind === "map") continue; + const fullKey = prefix + field.localName; + if (field.fieldKind === "message") { + if (isUnrepresentableWellKnownType(field.message)) continue; + if (!isWellKnownType(field.message)) { + const nested = field.message; + candidates.push({ value: `${fullKey}.`, description: `${fullKey} (message)` }); + if (!visited.has(nested)) walk(nested, `${fullKey}.`); + continue; + } + // Representable well-known types (Timestamp, Duration, FieldMask, …) + // fall through to a single `key=` leaf since proto JSON serializes + // them as a scalar string. + } + candidates.push({ value: `${fullKey}=`, description: `Set ${fullKey}` }); + if (field.fieldKind === "enum") { + for (const v of field.enum.values) { + candidates.push({ value: `${fullKey}=${v.name}`, description: v.name }); + } + } else if (field.fieldKind === "scalar" && field.scalar === ScalarType.BOOL) { + candidates.push({ value: `${fullKey}=true`, description: "true" }); + candidates.push({ value: `${fullKey}=false`, description: "false" }); + } + } + visited.delete(message); + } + + walk(method.input, ""); + return candidates; +} + +/** + * Resolves the leaf `DescField` at a dotted `--field` path within an input + * message tree. Returns undefined when any segment is missing or when a + * non-leaf segment isn't a singular message — callers fall back to treating + * the value as a string in that case. + * @param input - The RPC input message descriptor + * @param path - Dot-split path segments to follow (e.g. ["application", "name"]) + * @returns The leaf field descriptor, or undefined when the path doesn't resolve + */ +export function resolveLeafField(input: DescMessage, path: string[]): DescField | undefined { + let message = input; + for (let i = 0; i < path.length; i++) { + const field = message.fields.find((f) => f.localName === path[i]); + if (!field) return undefined; + if (i === path.length - 1) { + // Refuse to resolve a leaf inside an unrepresentable well-known type — + // even if the user typed `payload=...` for a Struct field manually, + // we don't want to claim a scalar shape we can't actually coerce. + if (field.fieldKind === "message" && isUnrepresentableWellKnownType(field.message)) { + return undefined; + } + return field; + } + // Mirror enumerateAllFieldCompletions: don't drill into list/map fields + // or well-known types — `--field` cannot represent their internal shape. + if (field.fieldKind !== "message") return undefined; + if (isWellKnownType(field.message)) return undefined; + message = field.message; + } + return undefined; +} diff --git a/packages/sdk/src/cli/commands/api/render.ts b/packages/sdk/src/cli/commands/api/render.ts index c8b2ad47d..6f28fa46f 100644 --- a/packages/sdk/src/cli/commands/api/render.ts +++ b/packages/sdk/src/cli/commands/api/render.ts @@ -1,4 +1,5 @@ import { ScalarType } from "@bufbuild/protobuf"; +import { nestedMessage } from "./proto-reflect"; import type { DescEnum, DescField, DescMessage, DescMethodUnary } from "@bufbuild/protobuf"; export interface InspectMessageJson { @@ -100,13 +101,6 @@ export function describeFieldType(field: DescField): string { } } -function nestedMessageForInspect(field: DescField): DescMessage | undefined { - if (field.fieldKind === "message") return field.message; - if (field.fieldKind === "list" && field.listKind === "message") return field.message; - if (field.fieldKind === "map" && field.mapKind === "message") return field.message; - return undefined; -} - function fieldToJson(field: DescField, visited: Set): InspectFieldJson { const json: InspectFieldJson = { name: field.localName, @@ -123,7 +117,7 @@ function fieldToJson(field: DescField, visited: Set): InspectFieldJ json.enumValues = fieldEnum.values.map((v) => v.name); } - const nested = nestedMessageForInspect(field); + const nested = nestedMessage(field); if (nested && !visited.has(nested)) { visited.add(nested); json.message = { @@ -161,7 +155,7 @@ function renderFieldText(field: DescField, indent: string, visited: Set ({ describe("shell completion", () => { describe("subcommand completion", () => { - it("completes root subcommands", () => { + it("completes root subcommands", async () => { const ctx = parseCompletionContext([""], mainCommand); - const result = generateCandidates(ctx); + const result = await generateCandidates(ctx, { shell: "bash" }); const values = result.candidates.map((c) => c.value); expect(values).toContain("deploy"); @@ -30,9 +32,9 @@ describe("shell completion", () => { expect(values).toContain("completion"); }); - it("completes nested subcommands for tailordb", () => { + it("completes nested subcommands for tailordb", async () => { const ctx = parseCompletionContext(["tailordb", ""], mainCommand); - const result = generateCandidates(ctx); + const result = await generateCandidates(ctx, { shell: "bash" }); const values = result.candidates.map((c) => c.value); expect(values).toContain("erd"); @@ -42,9 +44,9 @@ describe("shell completion", () => { }); describe("option name completion", () => { - it("completes option names for deploy command", () => { + it("completes option names for deploy command", async () => { const ctx = parseCompletionContext(["deploy", "--"], mainCommand); - const result = generateCandidates(ctx); + const result = await generateCandidates(ctx, { shell: "bash" }); const values = result.candidates.map((c) => c.value); expect(values).toContain("--config"); @@ -53,9 +55,9 @@ describe("shell completion", () => { expect(values).toContain("--yes"); }); - it("completes option names for workspace create command", () => { + it("completes option names for workspace create command", async () => { const ctx = parseCompletionContext(["workspace", "create", "--"], mainCommand); - const result = generateCandidates(ctx); + const result = await generateCandidates(ctx, { shell: "bash" }); const values = result.candidates.map((c) => c.value); expect(values).toContain("--name"); @@ -65,9 +67,9 @@ describe("shell completion", () => { }); describe("file completion", () => { - it("triggers file completion with extension filter for --config", () => { + it("triggers file completion with extension filter for --config", async () => { const ctx = parseCompletionContext(["deploy", "--config", ""], mainCommand); - const result = generateCandidates(ctx); + const result = await generateCandidates(ctx, { shell: "bash" }); // With extensions set, politty uses @ext: metadata instead of FileCompletion directive expect(result.fileExtensions).toEqual(["ts"]); @@ -78,47 +80,47 @@ describe("shell completion", () => { }); describe("directory completion", () => { - it("triggers directory completion for staticwebsite deploy --dir", () => { + it("triggers directory completion for staticwebsite deploy --dir", async () => { const ctx = parseCompletionContext(["staticwebsite", "deploy", "--dir", ""], mainCommand); - const result = generateCandidates(ctx); + const result = await generateCandidates(ctx, { shell: "bash" }); expect(result.directive & CompletionDirective.DirectoryCompletion).toBeTruthy(); }); - it("triggers directory completion for tailordb erd export --output", () => { + it("triggers directory completion for tailordb erd export --output", async () => { const ctx = parseCompletionContext( ["tailordb", "erd", "export", "--output", ""], mainCommand, ); - const result = generateCandidates(ctx); + const result = await generateCandidates(ctx, { shell: "bash" }); expect(result.directive & CompletionDirective.DirectoryCompletion).toBeTruthy(); }); }); describe("no file completion", () => { - it("suppresses file completion for --workspace-id", () => { + it("suppresses file completion for --workspace-id", async () => { const ctx = parseCompletionContext(["deploy", "--workspace-id", ""], mainCommand); - const result = generateCandidates(ctx); + const result = await generateCandidates(ctx, { shell: "bash" }); expect(result.directive & CompletionDirective.NoFileCompletion).toBeTruthy(); }); - it("suppresses file completion for --profile", () => { + it("suppresses file completion for --profile", async () => { const ctx = parseCompletionContext(["deploy", "--profile", ""], mainCommand); - const result = generateCandidates(ctx); + const result = await generateCandidates(ctx, { shell: "bash" }); expect(result.directive & CompletionDirective.NoFileCompletion).toBeTruthy(); }); }); describe("enum completion", () => { - it("completes role values for workspace user invite", () => { + it("completes role values for workspace user invite", async () => { const ctx = parseCompletionContext( ["workspace", "user", "invite", "--role", ""], mainCommand, ); - const result = generateCandidates(ctx); + const result = await generateCandidates(ctx, { shell: "bash" }); const values = result.candidates.map((c) => c.value); expect(values).toContain("admin"); @@ -127,4 +129,91 @@ describe("shell completion", () => { expect(result.directive & CompletionDirective.NoFileCompletion).toBeTruthy(); }); }); + + describe("api --field expand completion", () => { + // `--field` uses politty's `expand` variant: candidates are pre-enumerated + // at script-generation time keyed by the `endpoint` positional. The + // dynamic `generateCandidates` path returns no candidates for expand — + // candidates live in the resolved `valueCompletion.table` instead, and + // shells dispatch via a case lookup at TAB time. + function getFieldExpandTable(): { + dependsOn: readonly string[]; + table: readonly { + key: readonly string[]; + candidates: readonly { value: string; description?: string }[]; + }[]; + } { + const data = extractCompletionData(mainCommand, "tailor-sdk"); + const apiCmd = data.command.subcommands.find((s) => s.name === "api"); + if (!apiCmd) throw new Error("api subcommand missing"); + const fieldOpt = apiCmd.options.find((o) => o.name === "field"); + if (!fieldOpt) throw new Error("--field option missing"); + const vc = fieldOpt.valueCompletion; + if (!vc || vc.type !== "expand") { + throw new Error(`expected expand completion, got ${vc?.type}`); + } + return { dependsOn: vc.dependsOn, table: vc.table }; + } + + function candidatesFor(endpoint: string): readonly { value: string; description?: string }[] { + const { table } = getFieldExpandTable(); + const row = table.find((r) => r.key[0] === endpoint); + if (!row) throw new Error(`no expand row for ${endpoint}`); + return row.candidates; + } + + it("depends on the endpoint positional", () => { + const { dependsOn } = getFieldExpandTable(); + expect(dependsOn).toEqual(["endpoint"]); + }); + + it("enumerates top-level fields for the endpoint's proto schema", () => { + const values = candidatesFor("GetFunctionExecution").map((c) => c.value); + expect(values).toContain("workspaceId="); + expect(values).toContain("executionId="); + }); + + it("keys the expand table for the fully-qualified endpoint form too", () => { + // `api` accepts both `GetApplication` and + // `tailor.v1.OperatorService/GetApplication`. politty's expand keys the + // static table by the literal `endpoint` value, so a row keyed by the + // bare name does not match when the user types the FQ form. Both forms + // must be present. + const values = candidatesFor("tailor.v1.OperatorService/GetFunctionExecution").map( + (c) => c.value, + ); + expect(values).toContain("workspaceId="); + expect(values).toContain("executionId="); + }); + + it("enumerates enum values inline alongside the key", () => { + const values = candidatesFor("ListWorkspaces").map((c) => c.value); + expect(values).toContain("pageDirection="); + expect(values).toContain("pageDirection=PAGE_DIRECTION_UNSPECIFIED"); + expect(values).toContain("pageDirection=PAGE_DIRECTION_ASC"); + expect(values).toContain("pageDirection=PAGE_DIRECTION_DESC"); + }); + + it("enumerates true/false inline for bool-typed fields", () => { + const values = candidatesFor("CreateWorkspace").map((c) => c.value); + expect(values).toContain("deleteProtection="); + expect(values).toContain("deleteProtection=true"); + expect(values).toContain("deleteProtection=false"); + }); + + it("bakes the expand table and dedup tracker into the generated shell script", () => { + // The whole point of `expand` is that candidates are inlined into the + // static script — no Node process is spawned per TAB. politty's shell + // generator additionally populates `_used_field_keys` from already-typed + // `key=value` args so the same key isn't offered twice when --field is + // repeated. Confirm both are wired up in the zsh script. + const { script } = generateCompletion(mainCommand, { + shell: "zsh", + programName: "tailor-sdk", + }); + expect(script).toMatch(/__tailor_sdk_expand_[a-z_]+__field=/); + expect(script).toContain("GetFunctionExecution"); + expect(script).toContain("_used_field_keys"); + }); + }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 11d4c779d..b2e928e05 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,9 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + politty: https://pkg.pr.new/politty@353?v=20260521d + importers: .: @@ -131,8 +134,8 @@ importers: specifier: 2.3.1 version: 2.3.1 politty: - specifier: 0.4.15 - version: 0.4.15(@clack/prompts@1.3.0)(@inquirer/prompts@8.4.3(@types/node@24.12.3))(zod@4.3.6) + specifier: https://pkg.pr.new/politty@353?v=20260521d + version: https://pkg.pr.new/politty@353?v=20260521d(@clack/prompts@1.3.0)(@inquirer/prompts@8.4.3(@types/node@24.12.3))(zod@4.3.6) zod: specifier: 4.3.6 version: 4.3.6 @@ -486,8 +489,8 @@ importers: specifier: 2.3.1 version: 2.3.1 politty: - specifier: 0.4.15 - version: 0.4.15(@clack/prompts@1.3.0)(@inquirer/prompts@8.4.3(@types/node@24.12.3))(zod@4.3.6) + specifier: https://pkg.pr.new/politty@353?v=20260521d + version: https://pkg.pr.new/politty@353?v=20260521d(@clack/prompts@1.3.0)(@inquirer/prompts@8.4.3(@types/node@24.12.3))(zod@4.3.6) rolldown: specifier: 1.0.1 version: 1.0.1 @@ -595,8 +598,8 @@ importers: specifier: 2.3.1 version: 2.3.1 politty: - specifier: 0.4.15 - version: 0.4.15(@clack/prompts@1.3.0)(@inquirer/prompts@8.4.3(@types/node@24.12.3))(zod@4.3.6) + specifier: https://pkg.pr.new/politty@353?v=20260521d + version: https://pkg.pr.new/politty@353?v=20260521d(@clack/prompts@1.3.0)(@inquirer/prompts@8.4.3(@types/node@24.12.3))(zod@4.3.6) semver: specifier: 7.7.4 version: 7.7.4 @@ -4113,8 +4116,9 @@ packages: resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} engines: {node: '>=4'} - politty@0.4.15: - resolution: {integrity: sha512-wAkRH9YNxkowQD+9Vu9BQ3Nv6K9goyfvUrzvpZV9NdNySe5Ozn+GPMVSdqWu0DF0aD27vd2+7ROC09LYmzdb6A==} + politty@https://pkg.pr.new/politty@353?v=20260521d: + resolution: {tarball: https://pkg.pr.new/politty@353?v=20260521d} + version: 0.4.15 engines: {node: '>=18'} peerDependencies: '@clack/prompts': ^0.10.0 || ^0.11.0 || ^1.0.0 @@ -7894,7 +7898,7 @@ snapshots: pluralize@8.0.0: {} - politty@0.4.15(@clack/prompts@1.3.0)(@inquirer/prompts@8.4.3(@types/node@24.12.3))(zod@4.3.6): + politty@https://pkg.pr.new/politty@353?v=20260521d(@clack/prompts@1.3.0)(@inquirer/prompts@8.4.3(@types/node@24.12.3))(zod@4.3.6): dependencies: string-width: 8.2.0 zod: 4.3.6 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c61aaf89e..b757727d1 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,9 @@ nodeLinker: hoisted saveExact: true +overrides: + politty: https://pkg.pr.new/politty@353?v=20260521d + minimumReleaseAge: 4320 # 3 days minimumReleaseAgeStrict: true minimumReleaseAgeIgnoreMissingTime: false