mirror of
https://github.com/LukeHagar/vercel.git
synced 2025-12-09 12:57:46 +00:00
[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:
@@ -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"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
90
packages/cli/src/util/extension/exec.ts
Normal file
90
packages/cli/src/util/extension/exec.ts
Normal 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`);
|
||||
}
|
||||
}
|
||||
44
packages/cli/src/util/extension/proxy.ts
Normal file
44
packages/cli/src/util/extension/proxy.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
16
packages/cli/test/fixtures/e2e/cli-extension-whoami/bin.js
vendored
Executable file
16
packages/cli/test/fixtures/e2e/cli-extension-whoami/bin.js
vendored
Executable 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}`);
|
||||
});
|
||||
6
packages/cli/test/fixtures/e2e/cli-extension-whoami/package.json
vendored
Normal file
6
packages/cli/test/fixtures/e2e/cli-extension-whoami/package.json
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "cli-extension-whoami",
|
||||
"bin": {
|
||||
"vercel-mywhoami": "bin.js"
|
||||
}
|
||||
}
|
||||
26
packages/cli/test/fixtures/e2e/cli-extension/package-lock.json
generated
vendored
Normal file
26
packages/cli/test/fixtures/e2e/cli-extension/package-lock.json
generated
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
6
packages/cli/test/fixtures/e2e/cli-extension/package.json
vendored
Normal file
6
packages/cli/test/fixtures/e2e/cli-extension/package.json
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"cli-extension-whoami": "file:../cli-extension-whoami"
|
||||
}
|
||||
}
|
||||
14
packages/cli/test/integration-3.test.ts
vendored
14
packages/cli/test/integration-3.test.ts
vendored
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user