Skip to content

Commit

Permalink
feat: Project and entrypoint inference (#195)
Browse files Browse the repository at this point in the history
  • Loading branch information
arnauorriols authored Nov 27, 2023
1 parent bd57e29 commit 48b6a35
Show file tree
Hide file tree
Showing 7 changed files with 246 additions and 19 deletions.
4 changes: 3 additions & 1 deletion deployctl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -82,6 +83,7 @@ const subcommand = args._.shift();
switch (subcommand) {
case "deploy":
await setDefaultsFromConfigFile(args);
await inferMissingConfig(args);
await deploySubcommand(args);
break;
case "upgrade":
Expand Down Expand Up @@ -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();
Expand Down
2 changes: 2 additions & 0 deletions deps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

// std
export {
basename,
dirname,
fromFileUrl,
join,
Expand All @@ -13,6 +14,7 @@ export {
export {
bold,
green,
magenta,
red,
yellow,
} from "https://deno.land/[email protected]/fmt/colors.ts";
Expand Down
2 changes: 1 addition & 1 deletion src/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ export function parseArgs(args: string[]) {
alias: {
"help": "h",
"version": "V",
"project": "p",
},
boolean: [
"help",
Expand Down Expand Up @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions src/config_file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.`,
);
}
Expand All @@ -194,7 +194,7 @@ export default {
config.path(),
(config satisfies ConfigFile).toFileContent(),
);
wait("").succeed(
wait("").start().succeed(
`${
existingConfig ? "Updated" : "Created"
} config file '${config.path()}'.`,
Expand Down
226 changes: 226 additions & 0 deletions src/config_inference.ts
Original file line number Diff line number Diff line change
@@ -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 `<org-name>-<repo-name>`
* - 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(
/[:\/]+(?<org>[^\/]+)\/(?<repo>[^\/]+?)(?:\.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<string | undefined> {
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<string | undefined> {
// 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*(?<url>.+)/,
)
?.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<string | undefined> {
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",
);
}
}
}
21 changes: 10 additions & 11 deletions src/subcommands/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,19 +89,18 @@ export default async function (rawArgs: Record<string, any>): Promise<void> {
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 = {
Expand All @@ -112,7 +111,7 @@ export default async function (rawArgs: Record<string, any>): Promise<void> {
.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)),
Expand Down Expand Up @@ -176,7 +175,7 @@ async function deploy(opts: DeployOpts): Promise<void> {
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;
Expand All @@ -194,10 +193,10 @@ async function deploy(opts: DeployOpts): Promise<void> {
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}`);
Expand Down
6 changes: 2 additions & 4 deletions src/subcommands/logs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,6 @@ export default async function (args: Args): Promise<void> {
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.");
Expand All @@ -108,8 +106,8 @@ export default async function (args: Args): Promise<void> {
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) {
Expand Down

0 comments on commit 48b6a35

Please sign in to comment.