Compare commits

...

2 Commits

Author SHA1 Message Date
Luis Meyer
7efe60d1dd [build] make esbuild watchable 2023-11-14 15:37:01 +01:00
Luis Meyer
c82399d6bf [cli] add store commands to create and list stores 2023-11-14 15:37:01 +01:00
12 changed files with 502 additions and 3 deletions

View File

@@ -17,6 +17,7 @@
"test-dev": "pnpm test test/dev/",
"coverage": "codecov",
"build": "node scripts/build.mjs",
"watch": "node scripts/build.mjs --watch",
"dev": "ts-node ./src/index.ts",
"type-check": "tsc --noEmit"
},

View File

@@ -40,6 +40,7 @@ export const help = () => `
projects Manages your Projects
rm | remove [id] Removes a deployment
secrets [name] Manages your global Secrets, for use in Environment Variables
stores Manages your Stores
teams Manages your teams
whoami Shows the username of the currently logged in user

View File

@@ -38,4 +38,5 @@ export default new Map([
['team', 'teams'],
['teams', 'teams'],
['whoami', 'whoami'],
['stores', 'stores'],
]);

View File

@@ -0,0 +1,62 @@
import { Command } from '../help';
import { packageName } from '../../util/pkg-name';
export const storesCommand: Command = {
name: 'stores',
description: 'CRUD commands for stores.',
arguments: [
{
name: 'command',
required: true,
},
],
subcommands: [
{
name: 'create',
description: 'Create a new store',
arguments: [],
options: [
{
name: 'type',
description: 'Set the store type to create',
shorthand: 't',
type: 'string',
deprecated: false,
multi: false,
},
{
name: 'name',
description: 'Set the name of your new store',
shorthand: 'n',
type: 'string',
deprecated: false,
multi: false,
},
],
examples: [
{
name: 'Create a new store',
value: [`${packageName} store create`],
},
],
},
{
name: 'list',
description: 'List all your stores',
arguments: [],
options: [],
examples: [],
},
],
options: [
{
name: 'yes',
description: 'Skip the confirmation prompts',
shorthand: 'y',
type: 'boolean',
deprecated: false,
multi: false,
},
],
examples: [],
};

View File

@@ -0,0 +1,183 @@
import type { ProjectLinked, ProjectNotLinked } from '@vercel-internals/types';
import Client from '../../util/client';
import list from '../../util/input/list';
import text from '../../util/input/text';
import { createStore } from '../../util/stores/create-store';
import { linkStore } from '../../util/stores/link-store';
type Options = {
'--type': string;
'--name': string;
};
type CreateOptions = {
client: Client;
projectLink: ProjectLinked | ProjectNotLinked;
opts: Partial<Options>;
};
const VALID_STORE_TYPES = { blob: 'blob', kv: 'kv', postgres: 'postgres' };
function validStoreType(storetype: string) {
return Object.values(VALID_STORE_TYPES).includes(storetype);
}
async function inquireStoreType({
client,
opts,
}: CreateOptions): Promise<string | undefined> {
const storeType =
opts['--type'] ??
(await list(client, {
choices: [
{
name: 'Blob - Fast object storage',
value: VALID_STORE_TYPES.blob,
short: VALID_STORE_TYPES.blob,
},
{
name: 'KV - Durable Redis',
value: VALID_STORE_TYPES.kv,
short: VALID_STORE_TYPES.kv,
},
{
name: 'Postgres - Serverless SQL',
value: VALID_STORE_TYPES.postgres,
short: VALID_STORE_TYPES.postgres,
},
],
message: 'What kind of store do you want to create?',
}));
if (!validStoreType(storeType)) {
const validTypes = Object.values(VALID_STORE_TYPES).join(', ');
client.output.error(
`Received invalid store type '${storeType}'. Valid types are: ${validTypes}.`
);
return;
}
if (!storeType) {
client.output.log('Canceled');
return;
}
return storeType;
}
function validStoreName(storename: string) {
return storename.length > 5;
}
async function inquireStoreName(
storeType: string,
{ opts, client, projectLink }: CreateOptions
): Promise<string | undefined> {
const name =
opts['--name'] ??
(await text({
label: 'Select a store name: ',
validateValue: validStoreName,
initialValue:
projectLink.status === 'linked'
? `${projectLink.project.name}-${storeType}`
: `my-${storeType}-store`,
}));
if (!validStoreName(name)) {
client.output.error(
`Invalid store name '${name}'. Store names must be at least 6 characters long.`
);
return;
}
if (!name) {
client.output.log('No name input');
return;
}
return name;
}
const POSTGRES_REGIONS = [
'aws-us-east-1',
'aws-us-east-2',
'aws-us-west-2',
'aws-eu-central-1',
'aws-ap-southeast-1',
];
async function creaetPostgresStore(name: string, { client }: CreateOptions) {
const { region } = await client.prompt({
type: 'list',
name: 'region',
message:
'In which region should your database reads and writes take place?',
choices: POSTGRES_REGIONS,
default: POSTGRES_REGIONS[0],
});
return createStore({ client, payload: { name, region }, type: 'postgres' });
}
async function maybeLinkStore(
{ id, name }: { id: string; name: string },
{ client, projectLink }: CreateOptions
): Promise<boolean> {
if (projectLink.status === 'not_linked') {
client.output.print(
`\nYou can link the store later in the Vercel dashboard.`
);
return true;
}
const linked = await linkStore({ client, link: projectLink, name, id });
if (!linked) {
client.output.error('Failed to link store');
return false;
}
return true;
}
export async function create(options: CreateOptions) {
const { client } = options;
const type = await inquireStoreType(options);
if (!type) {
return 1;
}
const name = await inquireStoreName(type, options);
if (!name) {
return 1;
}
let result;
switch (type) {
case 'blob':
result = await createStore({ client, payload: { name }, type: 'blob' });
break;
case 'kv':
result = await createStore({ client, payload: { name }, type: 'redis' });
break;
case 'postgres':
result = await creaetPostgresStore(name, options);
}
if (!result) {
return 1;
}
const linked = await maybeLinkStore(result, options);
if (!linked) {
return 1;
}
return 0;
}

View File

@@ -0,0 +1,70 @@
import Client from '../../util/client';
import getArgs from '../../util/get-args';
import getInvalidSubcommand from '../../util/get-invalid-subcommand';
import getSubcommand from '../../util/get-subcommand';
import handleError from '../../util/handle-error';
import { getLinkedProject } from '../../util/projects/link';
import { help } from '../help';
import { storesCommand } from './command';
import { create } from './create';
import { list } from './list';
const COMMAND_CONFIG = {
create: ['create'],
list: ['list', 'ls'],
};
export const STORAGE_API_PATH = '/v1/storage';
export default async function main(client: Client) {
let argv;
try {
argv = getArgs(client.argv.slice(2), {
'--name': String,
'--n': '--name',
'--type': String,
'--t': '--type',
});
} catch (error) {
handleError(error);
return 1;
}
if (argv['--help']) {
client.output.print(
help(storesCommand, { columns: client.stderr.columns })
);
return 2;
}
const subArgs = argv._.slice(1);
const { subcommand } = getSubcommand(subArgs, COMMAND_CONFIG);
const { cwd, output } = client;
const link = await getLinkedProject(client, cwd);
if (link.status === 'error') {
return link.exitCode;
}
if (link.status === 'linked') {
client.config.currentTeam =
link.org.type === 'team' ? link.org.id : undefined;
}
switch (subcommand) {
case 'create':
return create({ opts: argv, client, projectLink: link });
case 'list':
return list({ client });
default:
output.error(getInvalidSubcommand(COMMAND_CONFIG));
client.output.print(
help(storesCommand, { columns: client.stderr.columns })
);
return 2;
}
}

View File

@@ -0,0 +1,26 @@
import Client from '../../util/client';
import { listStores } from '../../util/stores/list-stores';
import table from 'text-table';
type ListOptions = {
client: Client;
};
export async function list({ client }: ListOptions) {
const stores = await listStores({ client });
if (!stores) {
return 1;
}
client.output.print(
`\n${table([
['Type', 'Name', 'Id'],
...stores
.sort((a, b) => (a.type > b.type ? 1 : -1))
.map(store => [store.type, store.name, store.id]),
])}\n`
);
return 0;
}

View File

@@ -597,6 +597,9 @@ const main = async () => {
case 'whoami':
func = require('./commands/whoami').default;
break;
case 'stores':
func = require('./commands/stores').default;
break;
default:
func = null;
break;

View File

@@ -0,0 +1,50 @@
import chalk from 'chalk';
import { JSONObject } from '@vercel-internals/types';
import { STORAGE_API_PATH } from '../../commands/stores';
import Client from '../client';
import stamp from '../output/stamp';
type CreateBlobResponse = {
store: {
id: string;
name: string;
};
};
export async function createStore(options: {
client: Client;
type: 'blob' | 'redis' | 'postgres';
payload: JSONObject;
}) {
const { client, type, payload } = options;
const pullStamp = stamp();
client.output.spinner('creating Blob store');
try {
const { store } = await client.fetch<CreateBlobResponse>(
`${STORAGE_API_PATH}/stores/${type}`,
{ method: 'POST', body: payload }
);
client.output.success(
`Created blob store ${chalk.bold(store.name)} ${chalk.gray(pullStamp())}`
);
return {
id: store.id,
name: store.name,
};
} catch (error) {
if (error instanceof Error) {
client.output.error(`Failed to create store: ${error.message}`);
return;
}
client.output.error(`Failed to create store: ${error}`);
}
}

View File

@@ -0,0 +1,63 @@
import chalk from 'chalk';
import { ProjectLinked } from '@vercel-internals/types';
import Client from '../client';
import { STORAGE_API_PATH } from '../../commands/stores';
import confirm from '../input/confirm';
import { getCommandName } from '../pkg-name';
export async function linkStore(options: {
name: string;
id: string;
client: Client;
link: ProjectLinked;
}) {
const {
client,
name,
id,
link: { project, org },
} = options;
const shouldLink = await confirm(
client,
`Should the ${chalk.bold(name)} store be linked to the ${chalk.bold(
project.name
)} project?`,
true
);
if (!shouldLink) {
return false;
}
try {
client.output.spinner('linking store');
await client.fetch(`${STORAGE_API_PATH}/stores/${id}/connections`, {
accountId: org.id,
method: 'POST',
body: {
projectId: project.id,
envVarEnvironments: ['production', 'preview', 'development'],
},
});
} catch {
return false;
}
client.output.success(
`Linked blob store ${chalk.bold(name)} to project ${chalk.bold(
project.name
)}\n`
);
client.output.print(
`Run ${getCommandName(
'env pull'
)} to download the newly created env variables.`
);
return true;
}

View File

@@ -0,0 +1,32 @@
import Client from '../client';
import { STORAGE_API_PATH } from '../../commands/stores';
type ListStoresResponse = {
stores: {
name: string;
id: string;
createdAt: number;
type: string;
}[];
};
export async function listStores(options: { client: Client }) {
const { client } = options;
client.output.spinner('fetching store list');
try {
const response = await client.fetch<ListStoresResponse>(
`${STORAGE_API_PATH}/stores`
);
return response.stores;
} catch (error) {
if (error instanceof Error) {
client.output.error(`Failed to fetch blob list: ${error.message}`);
return;
}
client.output.error(`Failed to fetch blob list: ${error}`);
}
}

13
utils/build.mjs vendored
View File

@@ -1,7 +1,7 @@
import execa from 'execa';
import ts from 'typescript';
import path from 'node:path';
import { build } from 'esbuild';
import { build, context } from 'esbuild';
import { fileURLToPath } from 'node:url';
function parseTsConfig(tsconfigPath) {
@@ -44,7 +44,7 @@ export async function esbuild(opts = {}, cwd = process.cwd()) {
let outdir = opts.outfile ? undefined : tsconfig.options.outDir;
await build({
const buildOptions = {
entryPoints,
format: 'cjs',
outdir,
@@ -52,7 +52,14 @@ export async function esbuild(opts = {}, cwd = process.cwd()) {
target: ts.ScriptTarget[tsconfig.options.target],
sourcemap: tsconfig.options.sourceMap,
...opts,
});
};
if (process.argv.includes('--watch')) {
const buildContext = await context({ logLevel: 'info', ...buildOptions });
await buildContext.watch();
} else {
await build(buildOptions);
}
}
export async function tsc() {