Skip to content

Commit

Permalink
feat: persist project config in deno.json (#193)
Browse files Browse the repository at this point in the history
  • Loading branch information
arnauorriols authored Nov 27, 2023
1 parent 5aa4fee commit bd57e29
Show file tree
Hide file tree
Showing 6 changed files with 304 additions and 26 deletions.
25 changes: 24 additions & 1 deletion deployctl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@
// 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 { wait } from "./src/utils/spinner.ts";

const help = `deployctl ${VERSION}
Command line tool for Deno Deploy.
Expand Down Expand Up @@ -79,12 +81,15 @@ if (Deno.isatty(Deno.stdin.rid)) {
const subcommand = args._.shift();
switch (subcommand) {
case "deploy":
await setDefaultsFromConfigFile(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 +104,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("").info(`Using config file '${config.path()}'`);
config.useAsDefaultFor(args);
// Set the effective config path for the rest of the execution
args.config = config.path();
}
}
}
2 changes: 2 additions & 0 deletions deps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@

// std
export {
dirname,
fromFileUrl,
join,
normalize,
relative,
resolve,
toFileUrl,
} from "https://deno.land/[email protected]/path/mod.ts";
Expand Down
4 changes: 4 additions & 0 deletions src/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export function parseArgs(args: string[]) {
"static",
"version",
"dry-run",
"save-config",
],
string: [
"project",
Expand All @@ -29,11 +30,14 @@ export function parseArgs(args: string[]) {
"levels",
"regions",
"limit",
"config",
"entrypoint",
],
collect: ["grep"],
default: {
static: true,
limit: "100",
config: Deno.env.get("DEPLOYCTL_CONFIG_FILE"),
},
});
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("").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("").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

0 comments on commit bd57e29

Please sign in to comment.