[cli] Add support for Vercel CLI Extensions (#9800)

# Vercel CLI Extensions

Adds a new mechanism to add additional sub-commands to Vercel CLI, inspired by how Git handles sub-commands:

* Extensions are standalone executables that Vercel CLI will spawn as a child process.
* The name of the executable must begin with `vercel-`. For example, to add a sub-command called `vercel example`, there should exist an executable called `vercel-example`.
* The executable can either be a npm package with a `"bin"` entry installed into the local project's workspace, or be globally available in the `$PATH`.
* Extensions can access the [Vercel REST API](https://vercel.com/docs/rest-api), pre-authenticated, by utilizing the `VERCEL_API` env var. Vercel CLI spawns a local HTTP server that adds the `Authorization` header and then proxies to the Vercel REST API.

## Environment Variables

A few environment variables which provide features and context to the extension:

| Name      | Description |
| ----------- | ----------- |
| `VERCEL_API`      | HTTP URL to access the pre-authenticated Vercel API.       |
| `VERCEL_TEAM_ID`  | Provided when the currently selected scope is a Team. |
| `VERCEL_DEBUG` | Provided when the `--debug` flag is used. The command _may_ output additional debug logging. |
| `VERCEL_HELP` | Provided when the `--help` flag is used. The command _should_ print the help output and then end with exit code **2**. |

## Example

```bash
#!/usr/bin/env bash
set -euo pipefail

echo Hi, from a Vercel CLI Extension!
user="$(curl -s "$VERCEL_API/v2/user" | jq -r .user.username)"
echo "Logged in as: $user"
```

Usage:

```
$ vc example
Vercel CLI 28.18.5
Hi, from a Vercel CLI Extension!
Logged in as: tootallnate
```
This commit is contained in:
Nathan Rajlich
2023-05-10 09:36:58 -07:00
committed by GitHub
parent fa443035f6
commit 8428632eb1
11 changed files with 356 additions and 101 deletions

View File

@@ -45,6 +45,7 @@
},
"devDependencies": {
"@alex_neo/jest-expect-message": "1.0.5",
"@edge-runtime/node-utils": "2.0.3",
"@next/env": "11.1.2",
"@sentry/node": "5.5.0",
"@sindresorhus/slugify": "0.11.0",
@@ -81,7 +82,7 @@
"@types/title": "3.4.1",
"@types/universal-analytics": "0.4.2",
"@types/update-notifier": "5.1.0",
"@types/which": "1.3.2",
"@types/which": "3.0.0",
"@types/write-json-file": "2.2.1",
"@types/yauzl-promise": "2.1.0",
"@vercel-internals/constants": "*",
@@ -167,6 +168,7 @@
"ts-node": "10.9.1",
"universal-analytics": "0.4.20",
"utility-types": "2.1.0",
"which": "3.0.0",
"write-json-file": "2.2.0",
"xdg-app-paths": "5.1.0",
"yauzl-promise": "2.1.3"

View File

@@ -52,8 +52,9 @@ import { getCommandName, getTitleName } from './util/pkg-name';
import doLoginPrompt from './util/login/prompt';
import type { AuthConfig, GlobalConfig } from '@vercel-internals/types';
import { VercelConfig } from '@vercel/client';
import box from './util/output/box';
import { ProxyAgent } from 'proxy-agent';
import box from './util/output/box';
import { execExtension } from './util/extension/exec';
import { help } from './args';
const VERCEL_DIR = getGlobalPathConfig();
@@ -143,10 +144,11 @@ const main = async () => {
return 1;
}
const cwd = argv['--cwd'];
let cwd = argv['--cwd'];
if (cwd) {
process.chdir(cwd);
}
cwd = process.cwd();
// The second argument to the command can be:
//
@@ -155,7 +157,6 @@ const main = async () => {
const targetOrSubcommand = argv._[2];
const subSubCommand = argv._[3];
// Currently no beta commands - add here as needed
const betaCommands: string[] = ['rollback'];
if (betaCommands.includes(targetOrSubcommand)) {
console.log(
@@ -275,11 +276,13 @@ const main = async () => {
argv: process.argv,
});
let subcommand;
// Gets populated to the subcommand name when a built-in is
// provided, otherwise it remains undefined for an extension
let subcommand: string | undefined = undefined;
// Check if we are deploying something
if (targetOrSubcommand) {
const targetPath = join(process.cwd(), targetOrSubcommand);
const targetPath = join(cwd, targetOrSubcommand);
const targetPathExists = existsSync(targetPath);
const subcommandExists =
GLOBAL_COMMANDS.has(targetOrSubcommand) ||
@@ -301,8 +304,7 @@ const main = async () => {
debug(`user supplied known subcommand: "${targetOrSubcommand}"`);
subcommand = targetOrSubcommand;
} else {
debug('user supplied a possible target for deployment');
subcommand = 'deploy';
debug('user supplied a possible target for deployment or an extension');
}
} else {
debug('user supplied no target, defaulting to deploy');
@@ -322,6 +324,7 @@ const main = async () => {
!client.argv.includes('-h') &&
!client.argv.includes('--help') &&
!argv['--token'] &&
subcommand &&
!subcommandsWithoutToken.includes(subcommand)
) {
if (isTTY) {
@@ -413,7 +416,8 @@ const main = async () => {
);
}
const targetCommand = commands.get(subcommand);
let targetCommand =
typeof subcommand === 'string' ? commands.get(subcommand) : undefined;
const scope = argv['--scope'] || argv['--team'] || localConfig?.scope;
if (
@@ -484,96 +488,123 @@ const main = async () => {
try {
const start = Date.now();
let func: any;
switch (targetCommand) {
case 'alias':
func = require('./commands/alias').default;
break;
case 'bisect':
func = require('./commands/bisect').default;
break;
case 'build':
func = require('./commands/build').default;
break;
case 'certs':
func = require('./commands/certs').default;
break;
case 'deploy':
func = require('./commands/deploy').default;
break;
case 'dev':
func = require('./commands/dev').default;
break;
case 'dns':
func = require('./commands/dns').default;
break;
case 'domains':
func = require('./commands/domains').default;
break;
case 'env':
func = require('./commands/env').default;
break;
case 'git':
func = require('./commands/git').default;
break;
case 'init':
func = require('./commands/init').default;
break;
case 'inspect':
func = require('./commands/inspect').default;
break;
case 'link':
func = require('./commands/link').default;
break;
case 'list':
func = require('./commands/list').default;
break;
case 'logs':
func = require('./commands/logs').default;
break;
case 'login':
func = require('./commands/login').default;
break;
case 'logout':
func = require('./commands/logout').default;
break;
case 'project':
func = require('./commands/project').default;
break;
case 'pull':
func = require('./commands/pull').default;
break;
case 'remove':
func = require('./commands/remove').default;
break;
case 'rollback':
func = require('./commands/rollback').default;
break;
case 'secrets':
func = require('./commands/secrets').default;
break;
case 'teams':
func = require('./commands/teams').default;
break;
case 'whoami':
func = require('./commands/whoami').default;
break;
default:
func = null;
break;
if (!targetCommand) {
// Set this for the metrics to record it at the end
targetCommand = argv._[2];
// Try to execute as an extension
try {
exitCode = await execExtension(
client,
targetCommand,
argv._.slice(3),
cwd
);
} catch (err: unknown) {
if (isErrnoException(err) && err.code === 'ENOENT') {
// Fall back to `vc deploy <dir>`
targetCommand = subcommand = 'deploy';
} else {
throw err;
}
}
}
if (!func || !targetCommand) {
const sub = param(subcommand);
output.error(`The ${sub} subcommand does not exist`);
return 1;
}
// Not using an `else` here because if the CLI extension
// was not found then we have to fall back to `vc deploy`
if (subcommand) {
let func: any;
switch (targetCommand) {
case 'alias':
func = require('./commands/alias').default;
break;
case 'bisect':
func = require('./commands/bisect').default;
break;
case 'build':
func = require('./commands/build').default;
break;
case 'certs':
func = require('./commands/certs').default;
break;
case 'deploy':
func = require('./commands/deploy').default;
break;
case 'dev':
func = require('./commands/dev').default;
break;
case 'dns':
func = require('./commands/dns').default;
break;
case 'domains':
func = require('./commands/domains').default;
break;
case 'env':
func = require('./commands/env').default;
break;
case 'git':
func = require('./commands/git').default;
break;
case 'init':
func = require('./commands/init').default;
break;
case 'inspect':
func = require('./commands/inspect').default;
break;
case 'link':
func = require('./commands/link').default;
break;
case 'list':
func = require('./commands/list').default;
break;
case 'logs':
func = require('./commands/logs').default;
break;
case 'login':
func = require('./commands/login').default;
break;
case 'logout':
func = require('./commands/logout').default;
break;
case 'project':
func = require('./commands/project').default;
break;
case 'pull':
func = require('./commands/pull').default;
break;
case 'remove':
func = require('./commands/remove').default;
break;
case 'rollback':
func = require('./commands/rollback').default;
break;
case 'secrets':
func = require('./commands/secrets').default;
break;
case 'teams':
func = require('./commands/teams').default;
break;
case 'whoami':
func = require('./commands/whoami').default;
break;
default:
func = null;
break;
}
if (func.default) {
func = func.default;
}
if (!func || !targetCommand) {
const sub = param(subcommand);
output.error(`The ${sub} subcommand does not exist`);
return 1;
}
exitCode = await func(client);
if (func.default) {
func = func.default;
}
exitCode = await func(client);
}
const end = Date.now() - start;
if (shouldCollectMetrics) {

View File

@@ -65,7 +65,7 @@ export default class Client extends EventEmitter implements Stdio {
localConfig?: VercelConfig;
localConfigPath?: string;
prompt!: inquirer.PromptModule;
private requestIdCounter: number;
requestIdCounter: number;
constructor(opts: ClientOptions) {
super();
@@ -133,7 +133,7 @@ export default class Client extends EventEmitter implements Stdio {
}, fetch(url, { agent: this.agent, ...opts, headers, body }));
}
fetch(url: string, opts: { json: false }): Promise<Response>;
fetch(url: string, opts: FetchOptions & { json: false }): Promise<Response>;
fetch<T>(url: string, opts?: FetchOptions): Promise<T>;
fetch(url: string, opts: FetchOptions = {}) {
return this.retry(async bail => {

View File

@@ -0,0 +1,90 @@
import which from 'which';
import execa from 'execa';
import { dirname } from 'path';
import { listen } from 'async-listen';
import { scanParentDirs, walkParentDirs } from '@vercel/build-utils';
import { createProxy } from './proxy';
import type Client from '../client';
/**
* Attempts to execute a Vercel CLI Extension.
*
* If the extension was found and executed, then the
* exit code is returned.
*
* If the program could not be found, then an `ENOENT`
* error is thrown.
*/
export async function execExtension(
client: Client,
name: string,
args: string[],
cwd: string
): Promise<number> {
const { debug } = client.output;
const extensionCommand = `vercel-${name}`;
const { packageJsonPath, lockfilePath } = await scanParentDirs(cwd);
const baseFile = lockfilePath || packageJsonPath;
let extensionPath: string | null = null;
if (baseFile) {
// Scan `node_modules/.bin` works for npm / pnpm / yarn v1
// TOOD: add support for Yarn PnP
extensionPath = await walkParentDirs({
base: dirname(baseFile),
start: cwd,
filename: `node_modules/.bin/${extensionCommand}`,
});
}
if (!extensionPath) {
// Attempt global `$PATH` lookup
extensionPath = which.sync(extensionCommand, { nothrow: true });
}
if (!extensionPath) {
debug(`failed to find extension command with name "${extensionCommand}"`);
throw new ENOENT(extensionCommand);
}
debug(`invoking extension: ${extensionPath}`);
const proxy = createProxy(client);
proxy.once('close', () => {
debug(`extension proxy server shut down`);
});
const proxyUrl = await listen(proxy, { port: 0, host: '127.0.0.1' });
const VERCEL_API = proxyUrl.href.replace(/\/$/, '');
debug(`extension proxy server listening at ${VERCEL_API}`);
const result = await execa(extensionPath, args, {
cwd,
reject: false,
stdio: 'inherit',
env: {
...process.env,
VERCEL_API,
// TODO:
// VERCEL_SCOPE
// VERCEL_DEBUG
// VERCEL_HELP
},
});
proxy.close();
if (result instanceof Error) {
debug(`error running extension: ${result.message}`);
}
return result.exitCode;
}
class ENOENT extends Error {
code = 'ENOENT';
constructor(command: string) {
super(`Command "${command}" not found`);
}
}

View File

@@ -0,0 +1,44 @@
import { createServer } from 'http';
import { Headers } from 'node-fetch';
import {
toOutgoingHeaders,
mergeIntoServerResponse,
buildToHeaders,
} from '@edge-runtime/node-utils';
import type { Server } from 'http';
import type Client from '../client';
const toHeaders = buildToHeaders({
// @ts-expect-error - `node-fetch` Headers is missing `getAll()`
Headers,
});
export function createProxy(client: Client): Server {
return createServer(async (req, res) => {
try {
// Proxy to the upstream Vercel REST API
const headers = toHeaders(req.headers);
headers.delete('host');
const fetchRes = await client.fetch(req.url || '/', {
headers,
method: req.method,
body: req.method === 'GET' || req.method === 'HEAD' ? undefined : req,
useCurrentTeam: false,
json: false,
});
res.statusCode = fetchRes.status;
mergeIntoServerResponse(
// @ts-expect-error - `node-fetch` Headers is missing `getAll()`
toOutgoingHeaders(fetchRes.headers),
res
);
fetchRes.body.pipe(res);
} catch (err: unknown) {
client.output.prettyError(err);
if (!res.headersSent) {
res.statusCode = 500;
res.end('Unexpected error during API call');
}
}
});
}

View File

@@ -0,0 +1,16 @@
#!/usr/bin/env node
const { get } = require('http');
const { VERCEL_API } = process.env;
console.log('Hello from a CLI extension!');
console.log(`VERCEL_API: ${VERCEL_API}`);
get(`${VERCEL_API}/v2/user`, async (res) => {
let body = '';
res.setEncoding('utf8');
for await (const chunk of res) {
body += chunk;
}
const data = JSON.parse(body);
console.log(`Username: ${data.user.username}`);
});

View File

@@ -0,0 +1,6 @@
{
"name": "cli-extension-whoami",
"bin": {
"vercel-mywhoami": "bin.js"
}
}

View File

@@ -0,0 +1,26 @@
{
"name": "cli-extension",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"dependencies": {
"cli-extension-whoami": "file:../cli-extension-whoami"
}
},
"../cli-extension-whoami": {
"bin": {
"vercel-mywhoami": "bin.js"
}
},
"node_modules/cli-extension-whoami": {
"resolved": "../cli-extension-whoami",
"link": true
}
},
"dependencies": {
"cli-extension-whoami": {
"version": "file:../cli-extension-whoami"
}
}
}

View File

@@ -0,0 +1,6 @@
{
"private": true,
"dependencies": {
"cli-extension-whoami": "file:../cli-extension-whoami"
}
}

View File

@@ -5,6 +5,7 @@ import { URL, parse as parseUrl } from 'url';
import semVer from 'semver';
import { Readable } from 'stream';
import { homedir } from 'os';
import { runNpmInstall } from '@vercel/build-utils';
import { execCli } from './helpers/exec';
import fetch, { RequestInit, RequestInfo } from 'node-fetch';
import retry from 'async-retry';
@@ -1420,6 +1421,19 @@ test('use build-env', async () => {
expect(content.trim()).toBe('bar');
});
test('should invoke CLI extension', async () => {
const fixture = path.join(__dirname, 'fixtures/e2e/cli-extension');
// Ensure the `.bin` is populated in the fixture
await runNpmInstall(fixture);
const output = await execCli(binaryPath, ['mywhoami'], { cwd: fixture });
const formatted = formatOutput(output);
expect(output.stdout, formatted).toContain('Hello from a CLI extension!');
expect(output.stdout, formatted).toContain('VERCEL_API: http://127.0.0.1:');
expect(output.stdout, formatted).toContain(`Username: ${contextName}`);
});
// NOTE: Order matters here. This must be the last test in the file.
test('default command should prompt login with empty auth.json', async () => {
await clearAuthConfig();