diff --git a/src/commands/Deployments/openDeploymentsLink.ts b/src/commands/Deployments/openDeploymentsLink.ts index c86e8d7..72b4e51 100644 --- a/src/commands/Deployments/openDeploymentsLink.ts +++ b/src/commands/Deployments/openDeploymentsLink.ts @@ -7,7 +7,10 @@ export class OpenDeploymentsLink implements Command { constructor(private readonly vercel: VercelManager) {} async execute() { const projectInfo = await this.vercel.project.getInfo(); + if (!projectInfo) return; const user = await this.vercel.user.getInfo(); + if (!user) + return void vscode.window.showErrorMessage("Could not get user info!"); const url = `https://vercel.com/${user.username}/${projectInfo.name}`; await vscode.commands.executeCommand("vscode.open", vscode.Uri.parse(url)); } diff --git a/src/commands/Environment/createEnvironment.ts b/src/commands/Environment/createEnvironment.ts index 11e2e9d..2fa1661 100644 --- a/src/commands/Environment/createEnvironment.ts +++ b/src/commands/Environment/createEnvironment.ts @@ -2,6 +2,7 @@ import { Blob } from "node-fetch"; import { window } from "vscode"; import type { Command } from "../../CommandManager"; import type { VercelManager } from "../../features/VercelManager"; +import { vercelTargets } from "../../features/models"; export class CreateEnvironment implements Command { public readonly id = "vercel.createEnvironment"; @@ -51,19 +52,11 @@ export class CreateEnvironment implements Command { if (value === undefined) return; //> Get targets from user - const options = [ - { - label: "Development", - alwaysShow: true, - picked: true, - }, - { label: "Preview", alwaysShow: true, picked: true }, - { - label: "Production", - alwaysShow: true, - picked: true, - }, - ]; + const options = vercelTargets.map(t => ({ + label: t, + alwaysShow: true, + picked: true, + })); /** User's choice of targets as a list of options*/ const targetsChoices = await window.showQuickPick(options, { @@ -72,8 +65,7 @@ export class CreateEnvironment implements Command { title: `Creating ${key}`, }); if (!targetsChoices || targetsChoices.length === 0) return; - const targets = targetsChoices.map(x => x.label.toLowerCase()); - + const targets = targetsChoices.map(x => x.label); await this.vercel.env.create(key, value, targets); } } diff --git a/src/commands/Environment/openEnvironmentLink.ts b/src/commands/Environment/openEnvironmentLink.ts index bf8506b..d356430 100644 --- a/src/commands/Environment/openEnvironmentLink.ts +++ b/src/commands/Environment/openEnvironmentLink.ts @@ -7,7 +7,10 @@ export class OpenEnvironmentLink implements Command { constructor(private readonly vercel: VercelManager) {} async execute() { const projectInfo = await this.vercel.project.getInfo(); + if (!projectInfo) return; const user = await this.vercel.user.getInfo(); + if (!user) + return void vscode.window.showErrorMessage("Could not get user info!"); const url = `https://vercel.com/${user.username}/${projectInfo.name}/settings/environment-variables`; await vscode.commands.executeCommand("vscode.open", vscode.Uri.parse(url)); } diff --git a/src/commands/Environment/setEnvironment.ts b/src/commands/Environment/setEnvironment.ts index eb58ae0..32e8f2a 100644 --- a/src/commands/Environment/setEnvironment.ts +++ b/src/commands/Environment/setEnvironment.ts @@ -1,6 +1,9 @@ import { window } from "vscode"; import type { Command } from "../../CommandManager"; -import type { VercelEnvironmentInformation } from "../../features/models"; +import { + vercelTargets, + type VercelEnvironmentInformation, +} from "../../features/models"; import type { VercelManager } from "../../features/VercelManager"; export class SetEnvironment implements Command { @@ -43,9 +46,9 @@ export class SetEnvironment implements Command { if (!id || !key) return; //> Find variable being changed - const env = envList.find( - env => env.id === id - ) as VercelEnvironmentInformation; + const env = envList.find(env => env.id === id); + if (!env) + return void window.showErrorMessage("Could not find environment!"); //> get user input for value(placeholder = original value) const currValue = env.value; @@ -61,51 +64,33 @@ export class SetEnvironment implements Command { //> Get user options of targets to apply to /** list of initial targets */ - const initialTargets: string[] = - typeof env.target === "string" ? [env.target] : env.target!; + const initialTargets = + typeof env.target === "string" ? [env.target] : env.target ?? []; - let targets: string[] | undefined; + let targets = initialTargets; /** Check if Arguments say only to edit values */ if (!command?.editing || command.editing === "TARGETS") { - /** Function to check if the original contained the target */ - const getPicked = (target: string) => initialTargets?.includes(target); - /** List of options available for user choice */ - const options = [ - { - label: "Development", - alwaysShow: true, - picked: getPicked("development"), - }, - { label: "Preview", alwaysShow: true, picked: getPicked("preview") }, - { - label: "Production", - alwaysShow: true, - picked: getPicked("production"), - }, - ]; + const options = vercelTargets.map(l => ({ + label: l, + alwaysShow: true, + picked: initialTargets.includes(l), + })); /** User's choice of targets as a list of options*/ const chosen = await window.showQuickPick(options, { canPickMany: true, - placeHolder: "Select environments to apply to (None or esc to cancel)", + placeHolder: "Select environments to apply to (esc to cancel)", title: `Editing ${key}`, }); + if (!chosen) return; /** List of choices as strings */ targets = chosen?.map(t => t.label); - } else { - /** Editing Values Through Arguments */ - targets = initialTargets; } //> Return if canceled - if (targets === undefined || targets === null || targets.length === 0) - return; + if (targets.length === 0) return; //> edit the env by **ID**, new value and targets - await this.vercel.env.edit( - id, - newValue, - targets.map(t => t.toLowerCase()) - ); + await this.vercel.env.edit(id, newValue, targets); } } diff --git a/src/features/VercelManager.ts b/src/features/VercelManager.ts index 7c5f14f..87d2de1 100644 --- a/src/features/VercelManager.ts +++ b/src/features/VercelManager.ts @@ -1,12 +1,13 @@ /* eslint-disable @typescript-eslint/naming-convention */ // 👆 because vercel API requires snake_case keys -import { workspace } from "vscode"; +import { window, workspace } from "vscode"; import { Api } from "../utils/Api"; import type { TokenManager } from "./TokenManager"; import type { Deployment, VercelEnvironmentInformation, VercelResponse, + VercelTargets, } from "./models"; import { getTokenOauth } from "../utils/oauth"; @@ -84,13 +85,14 @@ export class VercelManager { project = { getInfo: async (refresh: boolean = false) => { if (this.projectInfo !== null && !refresh) return this.projectInfo; + const selectedProject = this.selectedProject; + if (!selectedProject) + return void window.showErrorMessage("No project selected!"); const result = await Api.projectInfo( - { - projectId: this.selectedProject, - }, + { projectId: selectedProject }, await this.authHeader() ); - if (result.ok) return; + if (!result.ok) return; return (this.projectInfo = result); }, }; @@ -126,22 +128,15 @@ export class VercelManager { * * @param key Name of var to create * @param value Value to store in variable - * @param {("development" | "preview" | "production")[]} targets Deployment targets to set to + * @param target Deployment targets to set to */ - create: async (key: string, value: string, targets: string[]) => { + create: async (key: string, value: string, target: VercelTargets[]) => { + const projectId = this.selectedProject; + if (!projectId) + return void window.showErrorMessage("No project selected!"); await Api.environment.create( - { - projectId: this.selectedProject, - }, - { - headers: (await this.authHeader()).headers, - body: JSON.stringify({ - key, - value, - target: targets, - type: "encrypted", - }), - } + { projectId, body: { key, value, target, type: "encrypted" } }, + await this.authHeader() ); this.onDidEnvironmentsUpdated(); }, @@ -151,34 +146,33 @@ export class VercelManager { * @param id A string corresponding to the Vercel ID of the env variable */ remove: async (id: string) => { - await Api.environment.remove( - { - projectId: this.selectedProject, - id, - }, - await this.authHeader() - ); + const projectId = this.selectedProject; + if (!projectId) + return void window.showErrorMessage("No project selected!"); + await Api.environment.remove({ projectId, id }, await this.authHeader()); this.onDidEnvironmentsUpdated(); }, /** * * @param id A string corresponding the ID of the Environment Variable * @param value The value to set the Variable to - * @param {("development" | "preview" | "production")[]} targets Deployment targets to set to + * @param targets Deployment targets to set to */ - edit: async (id: string, value: string, targets: string[]) => { + edit: async (id: string, value: string, targets: VercelTargets[]) => { + const selectedProject = this.selectedProject; + if (!selectedProject) + return void window.showErrorMessage("No project selected!"); await Api.environment.edit( { - projectId: this.selectedProject, + projectId: selectedProject, id, + body: { + value, + target: targets, + }, }, { headers: (await this.authHeader()).headers, - - body: JSON.stringify({ - value, - target: targets, - }), } ); this.onDidEnvironmentsUpdated(); diff --git a/src/features/models.ts b/src/features/models.ts index eaba9cb..61ae1df 100644 --- a/src/features/models.ts +++ b/src/features/models.ts @@ -87,12 +87,11 @@ type Pagination = { next: number; //Timestamp that must be used to request the next page. prev: number; //Timestamp that must be used to request the previous page. }; -type targets = - | ("production" | "preview" | "development" | "preview" | "development")[] - | ("production" | "preview" | "development" | "preview" | "development"); +export const vercelTargets = ["production", "preview", "development"] as const; +export type VercelTargets = (typeof vercelTargets)[number]; export type VercelEnvironmentInformation = { - target?: targets; + target?: VercelTargets | VercelTargets[]; type?: "secret" | "system" | "encrypted" | "plain"; id?: string; key?: string; diff --git a/src/utils/Api.ts b/src/utils/Api.ts index ca3dfef..2f5ef12 100644 --- a/src/utils/Api.ts +++ b/src/utils/Api.ts @@ -1,10 +1,31 @@ +/* eslint-disable @typescript-eslint/naming-convention */ import type { RequestInit } from "node-fetch"; import fetch from "node-fetch"; import type { ParamMap } from "urlcat"; import urlcat from "urlcat"; import { window } from "vscode"; -import type { VercelResponse } from "../features/models"; -import { typeGuard } from "tsafe"; +import type { + VercelEnvironmentInformation, + VercelResponse, + VercelTargets, +} from "../features/models"; +import { objectKeys, typeGuard } from "tsafe"; + +/** The default TRet */ +type TRetType = Record | unknown[]; +/** The default TRequired */ +type TRequiredType = ParamMap | undefined; +/** The default TRequiredFetch */ +type TRequiredFetchType = RequestInit | undefined; +type AuthHeaders = { headers: { Authorization: string } }; +type RequestHook = < + Params extends ParamMap & TRequired, + Req extends RequestInit & TRequiredFetch, +>(opts: { + params: Params; + req: Req; + // eslint-disable-next-line @typescript-eslint/no-invalid-void-type +}) => { params: ParamMap; req: RequestInit } | undefined | null | void; export class Api { /** Combines two objects, combining any object properties down one level @@ -13,15 +34,25 @@ export class Api { * Any non-object properties are overwritten if on a, or overwrite if on b. * Both arguments should be objects. if they are not objects, you will likely get an empty object back. */ - private static mergeHeaders(a: unknown, b?: unknown) { - // eslint-disable-next-line eqeqeq - if (b == undefined && typeof a === "object") return { ...a }; - else if (typeof b !== "object" || b === null) return {}; - else if (typeof a !== "object" || a === null) return {}; + private static mergeHeaders(a: A, b?: undefined): A; + private static mergeHeaders( + a: A, + b: B + ): A & B; // This isn't *quite* correct, but it works for now. + private static mergeHeaders( + a: A, + b?: B + ): A & B; + private static mergeHeaders( + a: A, + b?: B + ) { + if (!b) return { ...a }; const isObject = (obj: any) => !!obj && typeof obj === "object"; - const r: { [k: string]: unknown } = { ...a }; - for (const key of Object.keys(b)) { + // The types required for the index is nearly impossible to figure out. + const r = { ...a } as Record; + for (const key of objectKeys(b as Record)) { const aVal = (a as Record)[key]; const bVal = (b as Record)[key]; if (key in a) { @@ -38,38 +69,58 @@ export class Api { private static base(path?: string, query?: ParamMap) { return urlcat(this.baseUrl, path ?? "", query ?? {}); } + /** * Sets up a function for fetching to the api at *initial* with the default options of *params* and *fetch* + * The hook will be called before constructing the url. If it returns a table, it's used instead. It may modify *params* and *req* * @param initial Initial configuration for the API call * @returns A function that performs a fetch with the options requested + * @typeparam TRet is the return type of the API call + * @typeparam TRequired is the required query parameters for the API call + * @typeparam TRequiredFetch is the required fetch options for the API call */ private static init< - T extends Record | unknown[], - >(initial: { path: string; params?: ParamMap; fetch?: RequestInit }) { + TRet extends TRetType = TRetType, + TRequired extends TRequiredType = TRequiredType, + TRequiredFetch extends TRequiredFetchType = TRequiredFetchType, + >(initial: { + path: string; + params?: ParamMap; + fetch?: RequestInit; + hook?: RequestHook; + }) { const initOpt = initial.params ?? {}; const initFetchOpt = initial.fetch ?? {}; - const path = initial.path; + const { path, hook } = initial; + type Ret = (TRet & { ok: true }) | (VercelResponse.error & { ok: false }); //> Returns a function for fetching return async ( - options?: ParamMap, - fetchOptions?: RequestInit - ): Promise<(T & { ok: true }) | (VercelResponse.error & { ok: false })> => { - options ??= {}; - const finalOptions = { ...initOpt, ...options }; - const finalFetchOptions: RequestInit = this.mergeHeaders( - initFetchOpt, - fetchOptions - ); + options: TRequired & (ParamMap | undefined), + fetchOptions: TRequiredFetch & (RequestInit | undefined) + ): Promise => { + options ??= {} as typeof options; + const mergedOptions = { ...initOpt, ...options }; + const mergedFetchOptions = this.mergeHeaders(initFetchOpt, fetchOptions); + + // Final merged after hook. Note: these have a broader type to allow hook to return anything. + let finalOptions: ParamMap = mergedOptions, + finalFetchOptions: RequestInit = mergedFetchOptions; + if (hook) { + const hookOptions = { params: mergedOptions, req: mergedFetchOptions }; + const res = hook(hookOptions); // This may modify them, or return them! + if (res) { + finalOptions = res.params; + finalFetchOptions = res.req; + } + } + const url = this.base(path, finalOptions); - const response = await fetch(url, finalFetchOptions).catch( - e => - ({ - error: { - code: "FetchError", - message: `A Network Error Occured: ${e}`, - }, - }) as const - ); + const response = await fetch(url, finalFetchOptions).catch(e => ({ + error: { + code: "FetchError", + message: `A Network Error Occured: ${e}`, + }, + })); if ("error" in response) return { ...response, ok: false }; //> Check for error and tell user const invalidResponseError = { @@ -80,7 +131,7 @@ export class Api { }; const data = (await response.json().then( - r => r as T | VercelResponse.error, + r => r as TRet | VercelResponse.error, () => null )) ?? invalidResponseError; if (typeGuard(data, "error" in data)) { @@ -98,45 +149,95 @@ export class Api { }; } - public static deployments = this.init({ + public static deployments = this.init< + VercelResponse.deployment, + { projectId: string; limit?: number }, + AuthHeaders + >({ path: "/v6/deployments", fetch: { method: "GET", }, }); - public static projectInfo = this.init({ + public static projectInfo = this.init< + VercelResponse.info.project, + { projectId: string }, + AuthHeaders + >({ path: "/v9/projects/:projectId", fetch: { method: "GET", }, }); - public static userInfo = this.init({ + public static userInfo = this.init< + VercelResponse.info.user, + TRequiredType, + AuthHeaders + >({ path: "/v2/user", fetch: { method: "GET", }, }); public static environment = { - getAll: this.init({ + getAll: this.init< + VercelResponse.environment.getAll, + { projectId: string }, + AuthHeaders + >({ path: "/v8/projects/:projectId/env", params: { decrypt: "true" }, fetch: { method: "GET", }, }), - remove: this.init({ + remove: this.init< + VercelResponse.environment.remove, + { projectId: string; id: string }, + AuthHeaders + >({ path: "/v9/projects/:projectId/env/:id", fetch: { method: "DELETE", }, }), - create: this.init({ + create: this.init< + VercelResponse.environment.create, + { + projectId: string; + body: { + key: string; + value: string; + target: VercelTargets[]; + type: NonNullable; + }; + }, + AuthHeaders + >({ + hook: o => ({ + // turn the body param into a URLSearchParams object + req: { ...o.req, body: JSON.stringify(o.params.body) }, + params: { ...o.params, body: undefined }, + }), path: "/v10/projects/:projectId/env", fetch: { method: "POST", }, }), - edit: this.init({ + edit: this.init< + VercelResponse.environment.edit, + { + projectId: string; + id: string; + body: { value: string; target: VercelTargets[] }; + }, + { headers: { Authorization: string } } + >({ + hook: o => ({ + // turn the body param into a URLSearchParams object + req: { ...o.req, body: JSON.stringify(o.params.body) }, + params: { ...o.params, body: undefined }, + }), path: "/v9/projects/:projectId/env/:id", fetch: { method: "PATCH", @@ -144,8 +245,22 @@ export class Api { }), }; public static oauth = { - /** Note: This requires a body with code, redirect_uri, client_id, and client_secret */ - accessToken: this.init({ + accessToken: this.init< + VercelResponse.oauth.accessToken, + { + body: { + code: string; + redirect_uri: string; + client_id: string; + client_secret: string; + }; + } + >({ + hook: o => ({ + // turn the body param into a URLSearchParams object + req: { ...o.req, body: new URLSearchParams(o.params.body) }, + params: { ...o.params, body: undefined }, + }), path: "/v2/oauth/access_token", fetch: { method: "POST", diff --git a/src/utils/oauth.ts b/src/utils/oauth.ts index 506eaad..e8db533 100644 --- a/src/utils/oauth.ts +++ b/src/utils/oauth.ts @@ -1,7 +1,6 @@ import * as vscode from "vscode"; import http from "http"; import { Api } from "./Api"; -import type { VercelResponse } from "../features/models"; import { listen } from "async-listen"; // These are constants configured in the vercel dashboard. They must match those values! const OAUTH_PORT = 9615; @@ -53,14 +52,17 @@ async function doOauth( } } async function getTokenFromCode(code: string): Promise { - const res = await Api.oauth.accessToken(undefined, { - body: new URLSearchParams({ - code: code, - redirect_uri: OAUTH_URL, // eslint-disable-line @typescript-eslint/naming-convention - client_id: CLIENT_ID, // eslint-disable-line @typescript-eslint/naming-convention - client_secret: CLIENT_SEC, // eslint-disable-line @typescript-eslint/naming-convention - }), - }); + const res = await Api.oauth.accessToken( + { + body: { + code: code, + redirect_uri: OAUTH_URL, // eslint-disable-line @typescript-eslint/naming-convention + client_id: CLIENT_ID, // eslint-disable-line @typescript-eslint/naming-convention + client_secret: CLIENT_SEC, // eslint-disable-line @typescript-eslint/naming-convention + }, + }, + undefined + ); if (!res.ok) return; return res.access_token; }