Skip to content

Commit fc007cd

Browse files
authored
cli: OpenAPI-driven subcommand fallback behind VERCEL_AUTO_API (#15989)
## Summary Adds OpenAPI-driven subcommand resolution behind the `VERCEL_AUTO_API=1` environment variable. When enabled: - **Top-level tag resolution** — unrecognized CLI tokens are matched against OpenAPI tags from `openapi.vercel.sh`, enabling `vercel <tag> <operationId>` (e.g. `vercel user getAuthUser`, `vercel teams list`). - **Per-command fallthrough** — commands like `vercel projects` fall through to OpenAPI tag/operation matching when a token doesn't match a native subcommand (e.g. `vercel projects getProject`). - **`x-vercel-cli` metadata** — operations opt in via `x-vercel-cli.supportedSubcommands: true` in the OpenAPI spec. Aliases (`x-vercel-cli.aliases`), positional arguments (`x-vercel-cli.kind: 'argument'`), and response formatting (`x-vercel-cli.displayColumns`) are all driven by the spec. - **Formatted output** — when `displayColumns` is present in the response schema, results render as a card (single object) or table (array) instead of raw JSON. - The `vercel api` subcommand itself is unchanged — it continues to handle direct endpoint paths and interactive mode only. ### New files - `commands/api/display-columns.ts` — card/table renderers driven by `displayColumns` dot-notation paths - `commands/api/operation-request-builder.ts` — builds API requests from OpenAPI operation specs with interactive prompting - `util/openapi/matches-cli-api-tag.ts` — checks if a string matches an OpenAPI tag - `util/openapi/resolve-by-tag-operation.ts` — resolves tag + operationId to an endpoint - `util/openapi/resolve-subcommand-with-openapi.ts` — merges native subcommands with OpenAPI fallback - `util/openapi/try-openapi-fallback.ts` — reusable fallback entry point used by top-level and per-command routing ## Test plan - [x] Unit tests for `matches-cli-api-tag`, `resolve-by-tag-operation`, `resolve-subcommand-with-openapi`, `operation-request-builder` - [x] Unit tests for `client._fetch` teamId preservation - [ ] Manual: `VERCEL_AUTO_API=1 vercel user getAuthUser` renders card with displayColumns - [ ] Manual: `VERCEL_AUTO_API=1 vercel teams list` renders table - [ ] Manual: `VERCEL_AUTO_API=1 vercel teams get <teamId>` renders card - [ ] Manual: `vercel user getAuthUser` (without env var) falls through to normal behavior - [ ] Manual: `vercel projects list` still works as native subcommand
1 parent 7a5b910 commit fc007cd

26 files changed

Lines changed: 163337 additions & 68 deletions

.changeset/api-tag-operationid.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'vercel': patch
3+
---
4+
5+
Add OpenAPI-driven subcommand fallback behind `VERCEL_AUTO_API=1`.
6+
7+
When the env var is set, unrecognized CLI tokens are matched against OpenAPI tags and operation IDs from `openapi.vercel.sh`. This enables `vercel <tag> <operationId>` (e.g. `vercel user getAuthUser`) at the top level, and per-command fallthrough (e.g. `vercel projects getProject`) when a token doesn't match a native subcommand.
8+
9+
When `x-vercel-cli.displayColumns` is present in the OpenAPI response schema, results render as a card (single object) or table (array) instead of raw JSON.

packages/cli/src/commands/api/command.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ export const apiCommand = {
6464
type: [String],
6565
argument: 'KEY=VALUE',
6666
deprecated: false,
67-
description: 'Add a string parameter (no type parsing)',
67+
description: 'Add a string option (no type parsing)',
6868
},
6969
{
7070
name: 'header',
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import chalk from 'chalk';
2+
3+
/**
4+
* Resolve a dot-notation path like "user.softBlock.blockedAt" against an
5+
* object tree. Returns `undefined` when any segment is missing.
6+
*
7+
* Paths may contain a `[]` marker (e.g. "teams[].name") which indicates
8+
* array traversal. Only the portion *after* `[]` is resolved here — callers
9+
* are expected to strip the array prefix first (see `parseArrayColumns`).
10+
*/
11+
function getByPath(obj: unknown, path: string): unknown {
12+
let current: unknown = obj;
13+
for (const segment of path.split('.')) {
14+
if (current == null || typeof current !== 'object') return undefined;
15+
current = (current as Record<string, unknown>)[segment];
16+
}
17+
return current;
18+
}
19+
20+
/**
21+
* Detect whether displayColumns use `[]` array syntax and, if so, extract the
22+
* array from `data` and return per-row column paths.
23+
*
24+
* For example, given columns `{ name: "teams[].name", slug: "teams[].slug" }`:
25+
* - Extracts `data.teams` as the row array
26+
* - Returns row-level columns `{ name: "name", slug: "slug" }`
27+
*
28+
* Returns `null` when the columns don't use `[]` syntax.
29+
*/
30+
export function parseArrayColumns(
31+
data: unknown,
32+
columns: Record<string, string>
33+
): { rows: unknown[]; rowColumns: Record<string, string> } | null {
34+
const entries = Object.entries(columns);
35+
const first = entries[0];
36+
if (!first) return null;
37+
38+
const bracketIdx = first[1].indexOf('[].');
39+
if (bracketIdx === -1) return null;
40+
41+
const arrayKey = first[1].slice(0, bracketIdx);
42+
43+
const rowColumns: Record<string, string> = {};
44+
for (const [label, path] of entries) {
45+
const prefix = path.slice(0, bracketIdx);
46+
if (prefix !== arrayKey || !path.startsWith(prefix + '[].')) {
47+
return null;
48+
}
49+
rowColumns[label] = path.slice(bracketIdx + 3);
50+
}
51+
52+
const arr = getByPath(data, arrayKey);
53+
if (!Array.isArray(arr)) return null;
54+
55+
return { rows: arr, rowColumns };
56+
}
57+
58+
function formatValue(value: unknown): string {
59+
if (value === null || value === undefined) return chalk.dim('–');
60+
if (typeof value === 'number') {
61+
if (value > 1_000_000_000_000 && value < 2_000_000_000_000) {
62+
return new Date(value).toISOString();
63+
}
64+
return String(value);
65+
}
66+
if (typeof value === 'boolean') return String(value);
67+
if (typeof value === 'string') return value;
68+
return JSON.stringify(value);
69+
}
70+
71+
/**
72+
* Render a single object as a vertical card:
73+
*
74+
* name Jeff See
75+
* id XPtk…
76+
* email jeff.see@vercel.com
77+
*/
78+
export function renderCard(
79+
data: unknown,
80+
columns: Record<string, string>
81+
): string {
82+
const entries = Object.entries(columns);
83+
const maxLabel = Math.max(...entries.map(([label]) => label.length));
84+
85+
const lines = entries.map(([label, path]) => {
86+
const value = getByPath(data, path);
87+
return ` ${chalk.gray(label.padEnd(maxLabel))} ${formatValue(value)}`;
88+
});
89+
90+
return lines.join('\n');
91+
}
92+
93+
/**
94+
* Render an array of objects as a table:
95+
*
96+
* name id email
97+
* Jeff See XPtk… jeff.see@vercel.com
98+
*/
99+
export function renderTable(
100+
rows: unknown[],
101+
columns: Record<string, string>
102+
): string {
103+
const entries = Object.entries(columns);
104+
105+
const headerRow = entries.map(([label]) => label);
106+
const dataRows = rows.map(row =>
107+
entries.map(([, path]) => formatValue(getByPath(row, path)))
108+
);
109+
110+
const widths = entries.map(([label], colIdx) => {
111+
const dataMax = dataRows.reduce(
112+
(max, row) => Math.max(max, stripAnsi(row[colIdx]).length),
113+
0
114+
);
115+
return Math.max(label.length, dataMax);
116+
});
117+
118+
const header = headerRow
119+
.map((h, i) => chalk.bold(h.padEnd(widths[i])))
120+
.join(' ');
121+
122+
const body = dataRows.map(row =>
123+
row
124+
.map((cell, i) => {
125+
const pad = widths[i] - stripAnsi(cell).length;
126+
return cell + ' '.repeat(Math.max(0, pad));
127+
})
128+
.join(' ')
129+
);
130+
131+
return [header, ...body].join('\n');
132+
}
133+
134+
function stripAnsi(str: string): string {
135+
// eslint-disable-next-line no-control-regex
136+
return str.replace(/\x1b\[[0-9;]*m/g, '');
137+
}

0 commit comments

Comments
 (0)