From 48b6a35425cdff6c475e877ce6ef8fc9517ab881 Mon Sep 17 00:00:00 2001 From: Arnau Orriols <4871949+arnauorriols@users.noreply.github.com> Date: Mon, 27 Nov 2023 14:53:11 +0100 Subject: [PATCH] feat: Project and entrypoint inference (#195) --- deployctl.ts | 4 +- deps.ts | 2 + src/args.ts | 2 +- src/config_file.ts | 4 +- src/config_inference.ts | 226 ++++++++++++++++++++++++++++++++++++++ src/subcommands/deploy.ts | 21 ++-- src/subcommands/logs.ts | 6 +- 7 files changed, 246 insertions(+), 19 deletions(-) create mode 100644 src/config_inference.ts diff --git a/deployctl.ts b/deployctl.ts index 8c7da92d..cef5b1ca 100755 --- a/deployctl.ts +++ b/deployctl.ts @@ -11,6 +11,7 @@ import logsSubcommand from "./src/subcommands/logs.ts"; import { MINIMUM_DENO_VERSION, VERSION } from "./src/version.ts"; import { fetchReleases, getConfigPaths } from "./src/utils/info.ts"; import configFile from "./src/config_file.ts"; +import inferMissingConfig from "./src/config_inference.ts"; import { wait } from "./src/utils/spinner.ts"; const help = `deployctl ${VERSION} @@ -82,6 +83,7 @@ const subcommand = args._.shift(); switch (subcommand) { case "deploy": await setDefaultsFromConfigFile(args); + await inferMissingConfig(args); await deploySubcommand(args); break; case "upgrade": @@ -115,7 +117,7 @@ async function setDefaultsFromConfigFile(args: Args) { error(`Could not find or read the config file '${args.config}'`); } if (config !== null) { - wait("").info(`Using config file '${config.path()}'`); + wait("").start().info(`Using config file '${config.path()}'`); config.useAsDefaultFor(args); // Set the effective config path for the rest of the execution args.config = config.path(); diff --git a/deps.ts b/deps.ts index 62b7c19d..978b9537 100644 --- a/deps.ts +++ b/deps.ts @@ -2,6 +2,7 @@ // std export { + basename, dirname, fromFileUrl, join, @@ -13,6 +14,7 @@ export { export { bold, green, + magenta, red, yellow, } from "https://deno.land/std@0.170.0/fmt/colors.ts"; diff --git a/src/args.ts b/src/args.ts index 13bc5277..92994e8e 100644 --- a/src/args.ts +++ b/src/args.ts @@ -7,7 +7,6 @@ export function parseArgs(args: string[]) { alias: { "help": "h", "version": "V", - "project": "p", }, boolean: [ "help", @@ -38,6 +37,7 @@ export function parseArgs(args: string[]) { static: true, limit: "100", config: Deno.env.get("DEPLOYCTL_CONFIG_FILE"), + token: Deno.env.get("DENO_DEPLOY_TOKEN"), }, }); return parsed; diff --git a/src/config_file.ts b/src/config_file.ts index 509f7412..6e74741f 100644 --- a/src/config_file.ts +++ b/src/config_file.ts @@ -179,7 +179,7 @@ export default { let config; if (existingConfig && existingConfig.hasDeployConfig() && !overwrite) { if (!existingConfig.eq(args)) { - wait("").info( + wait("").start().info( `Some of the config used differ from the config found in '${existingConfig.path()}'. Use --save-config to overwrite it.`, ); } @@ -194,7 +194,7 @@ export default { config.path(), (config satisfies ConfigFile).toFileContent(), ); - wait("").succeed( + wait("").start().succeed( `${ existingConfig ? "Updated" : "Created" } config file '${config.path()}'.`, diff --git a/src/config_inference.ts b/src/config_inference.ts new file mode 100644 index 00000000..c8efd90a --- /dev/null +++ b/src/config_inference.ts @@ -0,0 +1,226 @@ +// Copyright 2021 Deno Land Inc. All rights reserved. MIT license. + +import { basename, magenta } from "../deps.ts"; +import { API, APIError } from "./utils/api.ts"; +import TokenProvisioner from "./utils/access_token.ts"; +import { wait } from "./utils/spinner.ts"; +import { error } from "./error.ts"; + +const NONAMES = ["src", "lib", "code", "dist", "build", "shared", "public"]; + +/** Arguments inferred from context */ +interface InferredArgs { + project?: string; + entrypoint?: string; +} + +/** + * Infer name of the project. + * + * The name of the project is inferred from either of the following options, in order: + * - If the project is in a git repo, infer `-` + * - Otherwise, use the directory name from where DeployCTL is being executed, + * unless the name is useless like "src" or "dist". + */ +async function inferProject(api: API, dryRun: boolean) { + wait("").start().warn( + "No project name or ID provided with either the --project arg or a config file.", + ); + let projectName = await inferProjectFromOriginUrl() || inferProjectFromCWD(); + if (!projectName) { + return; + } + if (dryRun) { + wait("").start().succeed( + `Guessed project name '${projectName}'.`, + ); + wait({ text: "", indent: 3 }).start().info( + "This is a dry run. In a live run the guessed name might be different if this one is invalid or already used.", + ); + return projectName; + } + for (;;) { + let spinner; + if (projectName) { + spinner = wait( + `Guessing project name '${projectName}': creating project...`, + ).start(); + } else { + spinner = wait("Creating new project with a random name...").start(); + } + try { + const project = await api.createProject(projectName); + if (projectName) { + spinner.succeed( + `Guessed project name '${project.name}'.`, + ); + } else { + spinner.succeed(`Created new project '${project.name}'`); + } + wait({ text: "", indent: 3 }).start().info( + `You can always change the project name in https://dash.deno.com/projects/${project.name}/settings`, + ); + return project.name; + } catch (e) { + if (e instanceof APIError && e.code == "projectNameInUse") { + spinner.stop(); + spinner = wait( + `Guessing project name '${projectName}': this project name is already used. Checking ownership...`, + ).start(); + const hasAccess = projectName && + (await api.getProject(projectName)) !== null; + if (hasAccess) { + spinner.stop(); + const confirmation = confirm( + `${ + magenta("?") + } Guessing project name '${projectName}': you already own this project. Should I deploy to it?`, + ); + if (confirmation) { + return projectName; + } + } + projectName = `${projectName}-${Math.floor(Math.random() * 100)}`; + spinner.stop(); + } else if (e instanceof APIError && e.code == "slugInvalid") { + // Fallback to random name given by the API + projectName = undefined; + spinner.stop(); + } else { + spinner.fail( + `Guessing project name '${projectName}': Creating project...`, + ); + error(e.code); + } + } + } +} + +async function inferProjectFromOriginUrl() { + let originUrl = await getOriginUrlUsingGitCmd(); + if (!originUrl) { + originUrl = await getOriginUrlUsingFS(); + } + if (!originUrl) { + return; + } + const result = originUrl.match( + /[:\/]+(?[^\/]+)\/(?[^\/]+?)(?:\.git)?$/, + )?.groups; + if (result) { + return `${result.org}-${result.repo}`; + } +} + +function inferProjectFromCWD() { + const projectName = basename(Deno.cwd()) + .toLowerCase() + .replaceAll(/[\s_]/g, "-") + .replaceAll(/[^a-z,A-Z,-]/g, "") + .slice(0, 26); + if (NONAMES.every((n) => n !== projectName)) { + return projectName; + } +} + +/** Try getting the origin remote URL using the git command */ +async function getOriginUrlUsingGitCmd(): Promise { + try { + const cmd = await new Deno.Command("git", { + args: ["remote", "get-url", "origin"], + }).output(); + if (cmd.stdout.length !== 0) { + return new TextDecoder().decode(cmd.stdout).trim(); + } + } catch (_) { + return; + } +} + +/** Try getting the origin remote URL reading the .git/config file */ +async function getOriginUrlUsingFS(): Promise { + // We assume cwd is the root of the repo. We favor false-negatives over false-positives, and this + // is a last-resort fallback anyway + try { + const config: string = await Deno.readTextFile(".git/config"); + const originSectionStart = config.indexOf('[remote "origin"]'); + const originSectionEnd = config.indexOf("[", originSectionStart + 1); + return config.slice(originSectionStart, originSectionEnd).match( + /url\s*=\s*(?.+)/, + ) + ?.groups + ?.url + ?.trim(); + } catch { + return; + } +} + +const ENTRYPOINT_PATHS = ["main", "index", "src/main", "src/index"]; +const ENTRYPOINT_EXTENSIONS = ["ts", "js", "tsx", "jsx"]; + +/** + * Infer the entrypoint of the project + * + * The current algorithm infers the entrypoint if one and only one of the following + * files is found: + * - main.[tsx|ts|jsx|js] + * - index.[tsx|ts|jsx|js] + * - src/main.[tsx|ts|jsx|js] + * - src/index.[tsx|ts|jsx|js] + */ +async function inferEntrypoint() { + const candidates = []; + for (const path of ENTRYPOINT_PATHS) { + for (const extension of ENTRYPOINT_EXTENSIONS) { + candidates.push(present(`${path}.${extension}`)); + } + } + const candidatesPresent = (await Promise.all(candidates)).filter((c) => + c !== undefined + ); + if (candidatesPresent.length === 1) { + return candidatesPresent[0]; + } else { + return; + } +} + +async function present(path: string): Promise { + try { + await Deno.lstat(path); + return path; + } catch { + return; + } +} + +export default async function inferMissingConfig( + args: InferredArgs & { + token?: string; + help?: boolean; + version?: boolean; + "dry-run"?: boolean; + }, +) { + if (args.help || args.version) { + return; + } + const api = args.token + ? API.fromToken(args.token) + : API.withTokenProvisioner(TokenProvisioner); + if (args.project === undefined) { + args.project = await inferProject(api, !!args["dry-run"]); + } + if (args.entrypoint === undefined) { + args.entrypoint = await inferEntrypoint(); + if (args.entrypoint) { + wait("").start().warn( + `No entrypoint provided with either the --entrypoint arg or a config file. I've guessed '${args.entrypoint}' for you.`, + ); + wait({ text: "", indent: 3 }).start().info( + "Is this wrong? Please let us know in https://github.com/denoland/deployctl/issues/new", + ); + } + } +} diff --git a/src/subcommands/deploy.ts b/src/subcommands/deploy.ts index ddc50252..d65bc7da 100644 --- a/src/subcommands/deploy.ts +++ b/src/subcommands/deploy.ts @@ -89,19 +89,18 @@ export default async function (rawArgs: Record): Promise { console.log(help); Deno.exit(0); } - const token = args.token ?? Deno.env.get("DENO_DEPLOY_TOKEN") ?? null; - if (args.entrypoint === null) { - console.error(help); - error("No entrypoint specifier given."); + error( + "Unable to guess the entrypoint of this project. Use the --entrypoint argument to provide one.", + ); } if (rawArgs._.length > 1) { - console.error(help); error("Too many positional arguments given."); } if (args.project === null) { - console.error(help); - error("Missing project ID."); + error( + "Unable to guess a project name for this project. Use the --project argument to provide one.", + ); } const opts = { @@ -112,7 +111,7 @@ export default async function (rawArgs: Record): Promise { .catch((e) => error(e)), static: args.static, prod: args.prod, - token, + token: args.token, project: args.project, include: args.include?.map((pattern) => normalize(pattern)), exclude: args.exclude?.map((pattern) => normalize(pattern)), @@ -176,7 +175,7 @@ async function deploy(opts: DeployOpts): Promise { Deno.exit(1); } const [projectDeployments, _pagination] = deploymentsListing!; - projectInfoSpinner.succeed(`Project: ${project.name}`); + projectInfoSpinner.succeed(`Deploying to project ${project.name}.`); if (projectDeployments.length === 0) { projectIsEmpty = true; @@ -194,10 +193,10 @@ async function deploy(opts: DeployOpts): Promise { if (url.protocol === "file:") { const path = fromFileUrl(url); if (!path.startsWith(cwd)) { - wait("").fail(`Entrypoint: ${path}`); + wait("").start().fail(`Entrypoint: ${path}`); error("Entrypoint must be in the current working directory."); } else { - wait("").succeed(`Entrypoint: ${path}`); + wait("").start().succeed(`Entrypoint: ${path}`); } const entrypoint = path.slice(cwd.length); url = new URL(`file:///src${entrypoint}`); diff --git a/src/subcommands/logs.ts b/src/subcommands/logs.ts index efadf72a..96741ce9 100644 --- a/src/subcommands/logs.ts +++ b/src/subcommands/logs.ts @@ -84,8 +84,6 @@ export default async function (args: Args): Promise { console.log(help); Deno.exit(0); } - const token = logSubcommandArgs.token ?? Deno.env.get("DENO_DEPLOY_TOKEN") ?? - null; if (logSubcommandArgs.project === null) { console.error(help); error("Missing project ID."); @@ -108,8 +106,8 @@ export default async function (args: Args): Promise { error("--since must be earlier than --until"); } - const api = token - ? API.fromToken(token) + const api = logSubcommandArgs.token + ? API.fromToken(logSubcommandArgs.token) : API.withTokenProvisioner(TokenProvisioner); const { regionCodes } = await api.getMetadata(); if (logSubcommandArgs.regions !== null) {