Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Project and entrypoint inference #195

Merged
merged 17 commits into from
Nov 27, 2023
Merged
27 changes: 26 additions & 1 deletion deployctl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,16 @@
// Copyright 2021 Deno Land Inc. All rights reserved. MIT license.

import { semverGreaterThanOrEquals } from "./deps.ts";
import { parseArgs } from "./src/args.ts";
import { Args, parseArgs } from "./src/args.ts";
import { error } from "./src/error.ts";
import deploySubcommand from "./src/subcommands/deploy.ts";
import upgradeSubcommand from "./src/subcommands/upgrade.ts";
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}
Command line tool for Deno Deploy.
Expand Down Expand Up @@ -79,12 +82,16 @@ if (Deno.isatty(Deno.stdin.rid)) {
const subcommand = args._.shift();
switch (subcommand) {
case "deploy":
await setDefaultsFromConfigFile(args);
await inferMissingConfig(args);
await deploySubcommand(args);
break;
case "upgrade":
await setDefaultsFromConfigFile(args);
await upgradeSubcommand(args);
break;
case "logs":
await setDefaultsFromConfigFile(args);
await logsSubcommand(args);
break;
default:
Expand All @@ -99,3 +106,21 @@ switch (subcommand) {
console.error(help);
Deno.exit(1);
}

async function setDefaultsFromConfigFile(args: Args) {
const loadFileConfig = !args.version && !args.help;
if (loadFileConfig) {
const config = await configFile.read(
args.config ?? configFile.cwdOrAncestors(),
);
if (config === null && args.config !== undefined && !args["save-config"]) {
error(`Could not find or read the config file '${args.config}'`);
}
if (config !== null) {
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();
}
}
}
4 changes: 4 additions & 0 deletions deps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,19 @@

// std
export {
basename,
dirname,
fromFileUrl,
join,
normalize,
relative,
resolve,
toFileUrl,
} from "https://deno.land/[email protected]/path/mod.ts";
export {
bold,
green,
magenta,
red,
yellow,
} from "https://deno.land/[email protected]/fmt/colors.ts";
Expand Down
6 changes: 5 additions & 1 deletion src/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,14 @@ export function parseArgs(args: string[]) {
alias: {
"help": "h",
"version": "V",
"project": "p",
},
boolean: [
"help",
"prod",
"static",
"version",
"dry-run",
"save-config",
],
string: [
"project",
Expand All @@ -29,11 +29,15 @@ export function parseArgs(args: string[]) {
"levels",
"regions",
"limit",
"config",
"entrypoint",
],
collect: ["grep"],
default: {
static: true,
limit: "100",
config: Deno.env.get("DEPLOYCTL_CONFIG_FILE"),
token: Deno.env.get("DENO_DEPLOY_TOKEN"),
},
});
return parsed;
Expand Down
216 changes: 216 additions & 0 deletions src/config_file.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
// Copyright 2021 Deno Land Inc. All rights reserved. MIT license.

import { dirname, join, relative, resolve } from "../deps.ts";
import { error } from "./error.ts";
import { isURL } from "./utils/entrypoint.ts";
import { wait } from "./utils/spinner.ts";

const DEFAULT_FILENAME = "deno.json";

/** Arguments persisted in the deno.json config file */
interface ConfigArgs {
project?: string;
entrypoint?: string;
}

class ConfigFile {
#path: string;
#content: { deploy?: ConfigArgs };

constructor(path: string, content: { deploy?: ConfigArgs }) {
this.#path = path;
this.#content = {
...content,
deploy: content.deploy && this.normalize(content.deploy),
};
}

/**
* Create a new `ConfigFile` using an object that _at least_ contains the `ConfigArgs`.
*
* Ignores any property in `args` not meant to be persisted.
*/
static create(path: string, args: ConfigArgs) {
const config = new ConfigFile(path, { deploy: {} });
// Use override to clean-up args
config.override(args);
return config;
}

/**
* Override the `ConfigArgs` of this ConfigFile.
*
* Ignores any property in `args` not meant to be persisted.
*/
override(args: ConfigArgs) {
const normalizedArgs = this.normalize(args);
this.#content.deploy = normalizedArgs;
}

/**
* For every arg in `ConfigArgs`, if the `args` argument object does not contain
* the arg, fill it with the value in this `ConfigFile`, if any.
*/
useAsDefaultFor(args: ConfigArgs) {
for (const [key, thisValue] of Object.entries(this.args())) {
// deno-lint-ignore no-explicit-any
if ((args as any)[key] === undefined && thisValue) {
// deno-lint-ignore no-explicit-any
(args as any)[key] = thisValue;
}
}
}

/** Check if the `ConfigArgs` in this `ConfigFile` match `args`
*
* Ignores any property in `args` not meant to be persisted.
*/
eq(args: ConfigArgs) {
const otherConfigArgs = this.normalize(args);
// Iterate over the other args as they might include args not yet persisted in the config file
for (const [key, otherValue] of Object.entries(otherConfigArgs)) {
// deno-lint-ignore no-explicit-any
if ((this.args() as any)[key] !== otherValue) {
return false;
}
}
return true;
}

normalize(args: ConfigArgs): ConfigArgs {
// Copy object as normalization is internal to the config file
const normalizedArgs = {
project: args.project,
entrypoint: (args.entrypoint && !isURL(args.entrypoint))
? resolve(args.entrypoint)
// Backoff if entrypoint is URL, the user knows what they're doing
: args.entrypoint,
};
return normalizedArgs;
}

/** Return whether the `ConfigFile` has the `deploy` namespace */
hasDeployConfig() {
return this.#content.deploy !== undefined;
}

static fromFileContent(filepath: string, content: string) {
const parsedContent = JSON.parse(content);
const configContent = {
...parsedContent,
deploy: parsedContent.deploy && {
...parsedContent.deploy,
entrypoint: parsedContent.deploy.entrypoint &&
(isURL(parsedContent.deploy.entrypoint)
// Backoff if entrypoint is URL, the user knows what they're doing
? parsedContent.deploy.entrypoint
// entrypoint must be interpreted as absolute or relative to the config file
: resolve(dirname(filepath), parsedContent.deploy.entrypoint)),
},
};
return new ConfigFile(filepath, configContent);
}

toFileContent() {
const content = {
...this.#content,
deploy: this.#content.deploy && {
...this.#content.deploy,
entrypoint: this.#content.deploy.entrypoint &&
(isURL(this.#content.deploy.entrypoint)
// Backoff if entrypoint is URL, the user knows what they're doing
? this.#content.deploy.entrypoint
// entrypoint must be stored relative to the config file
: relative(dirname(this.#path), this.#content.deploy.entrypoint)),
},
};
return JSON.stringify(content, null, 2);
}

path() {
return this.#path;
}

args() {
return (this.#content.deploy ?? {});
}
}

export default {
/** Read a `ConfigFile` from disk */
async read(
path: string | Iterable<string>,
): Promise<ConfigFile | null> {
const paths = typeof path === "string" ? [path] : path;
for (const filepath of paths) {
let content;
try {
content = await Deno.readTextFile(filepath);
} catch {
// File not found, try next
continue;
}
try {
return ConfigFile.fromFileContent(filepath, content);
} catch (e) {
error(e);
}
}
// config file not found
return null;
},

/**
* Write `ConfigArgs` to the config file.
*
* @param path {string | null} path where to write the config file. If the file already exists and
* `override` is `true`, its content will be merged with the `args`
* argument. If null, will default to `DEFAULT_FILENAME`.
* @param args {ConfigArgs} args to be upserted into the config file.
* @param overwrite {boolean} control whether an existing config file should be overwritten.
*/
maybeWrite: async function (
path: string | null,
args: ConfigArgs,
overwrite: boolean,
): Promise<void> {
const pathOrDefault = path ?? DEFAULT_FILENAME;
const existingConfig = await this.read(pathOrDefault);
let config;
if (existingConfig && existingConfig.hasDeployConfig() && !overwrite) {
if (!existingConfig.eq(args)) {
wait("").start().info(
`Some of the config used differ from the config found in '${existingConfig.path()}'. Use --save-config to overwrite it.`,
);
}
return;
} else if (existingConfig) {
existingConfig.override(args);
config = existingConfig;
} else {
config = ConfigFile.create(pathOrDefault, args);
}
await Deno.writeTextFile(
config.path(),
(config satisfies ConfigFile).toFileContent(),
);
wait("").start().succeed(
`${
existingConfig ? "Updated" : "Created"
} config file '${config.path()}'.`,
);
},

cwdOrAncestors: function* () {
let wd = Deno.cwd();
while (wd) {
yield join(wd, DEFAULT_FILENAME);
const newWd = dirname(wd);
if (newWd === wd) {
return;
} else {
wd = newWd;
}
}
},
};
Loading