From c3ffffda225c1f237b2e74a5074ec8054d9993b1 Mon Sep 17 00:00:00 2001 From: Arnau Orriols Date: Sat, 11 Nov 2023 02:18:34 +0100 Subject: [PATCH] feat: persist project config in deno.json --- deployctl.ts | 13 +++ src/args.ts | 3 + src/config_file.ts | 172 ++++++++++++++++++++++++++++++++++++++ src/subcommands/deploy.ts | 19 ++++- 4 files changed, 204 insertions(+), 3 deletions(-) create mode 100644 src/config_file.ts diff --git a/deployctl.ts b/deployctl.ts index 1e3ac027..56eff9ae 100755 --- a/deployctl.ts +++ b/deployctl.ts @@ -10,6 +10,8 @@ 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. @@ -33,6 +35,17 @@ if (!semverGreaterThanOrEquals(Deno.version.deno, MINIMUM_DENO_VERSION)) { } const args = parseArgs(Deno.args); +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); + args.config = config.path(); +} if (Deno.isatty(Deno.stdin.rid)) { let latestVersion; diff --git a/src/args.ts b/src/args.ts index 22cc1a9a..dee5612a 100644 --- a/src/args.ts +++ b/src/args.ts @@ -15,6 +15,7 @@ export function parseArgs(args: string[]) { "static", "version", "dry-run", + "save-config" ], string: [ "project", @@ -29,11 +30,13 @@ export function parseArgs(args: string[]) { "levels", "regions", "limit", + "config", ], collect: ["grep"], default: { static: true, limit: "100", + config: Deno.env.get("DEPLOYCTL_CONFIG_FILE"), }, }); return parsed; diff --git a/src/config_file.ts b/src/config_file.ts new file mode 100644 index 00000000..a3993835 --- /dev/null +++ b/src/config_file.ts @@ -0,0 +1,172 @@ +// Copyright 2021 Deno Land Inc. All rights reserved. MIT license. + +import { dirname } from "https://deno.land/std@0.170.0/path/win32.ts"; +import { join } from "../deps.ts"; +import { error } from "./error.ts"; +import { wait } from "./utils/spinner.ts"; + +const DEFAULT_FILENAME = "deno.json"; + +/** Arguments persisted in the deno.json config file */ +interface ConfigArgs { + project?: string; +} + +class ConfigFile { + #path: string; + #content: { deploy?: ConfigArgs }; + + constructor(path: string, content: { deploy?: ConfigArgs }) { + this.#path = path; + this.#content = content; + } + + /** + * 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) { + if (this.#content.deploy === undefined) { + this.#content.deploy = {}; + } + // Update only specific properties as args might contain properties we don't want in the config file + this.#content.deploy.project = args.project; + } + + /** + * 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) { + if (args.project === undefined && this.#content.deploy?.project) { + args.project = this.#content.deploy?.project; + } + } + + /** Check if the `ConfigArgs` in this `ConfigFile` match `args` + * + * Ignores any property in `args` not meant to be persisted. + */ + eq(args: ConfigArgs) { + const thisContent = (this.#content.deploy ?? {}) as { + [x: string]: unknown; + }; + const otherConfig = ConfigFile.create(this.#path, args); + for (const [key, otherValue] of Object.entries(otherConfig.args())) { + if (thisContent[key] !== otherValue) { + return false; + } + } + return true; + } + + /** Return whether the `ConfigFile` has the `deploy` namespace */ + hasDeployConfig() { + return this.#content.deploy !== undefined; + } + + stringify() { + return JSON.stringify(this.#content, null, 2); + } + + path() { + return this.#path; + } + + args() { + return (this.#content.deploy ?? {}); + } +} + +export default { + /** Read a `ConfigFile` from disk */ + async read( + path: string | Iterable, + ): Promise { + 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 { + const parsedContent = JSON.parse(content); + return new ConfigFile(filepath, parsedContent); + } 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 { + 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).stringify(), + ); + 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; + } + } + }, +}; diff --git a/src/subcommands/deploy.ts b/src/subcommands/deploy.ts index 0e77a062..6535f3c8 100644 --- a/src/subcommands/deploy.ts +++ b/src/subcommands/deploy.ts @@ -2,6 +2,7 @@ import { fromFileUrl, normalize, Spinner } from "../../deps.ts"; import { wait } from "../utils/spinner.ts"; +import configFile from "../config_file.ts"; import { error } from "../error.ts"; import { API, APIError } from "../utils/api.ts"; import { ManifestEntry } from "../utils/api_types.ts"; @@ -49,6 +50,8 @@ export interface Args { project: string | null; importMap: string | null; dryRun: boolean; + config: string | null; + saveConfig: boolean; } // deno-lint-ignore no-explicit-any @@ -63,6 +66,8 @@ export default async function (rawArgs: Record): Promise { exclude: rawArgs.exclude?.split(","), include: rawArgs.include?.split(","), dryRun: !!rawArgs["dry-run"], + config: rawArgs.config ? String(rawArgs.config) : null, + saveConfig: !!rawArgs["save-config"], }; const entrypoint: string | null = typeof rawArgs._[0] === "string" ? rawArgs._[0] @@ -98,6 +103,8 @@ export default async function (rawArgs: Record): Promise { include: args.include?.map((pattern) => normalize(pattern)), exclude: args.exclude?.map((pattern) => normalize(pattern)), dryRun: args.dryRun, + config: args.config, + saveConfig: args.saveConfig, }; await deploy(opts); @@ -113,6 +120,8 @@ interface DeployOpts { token: string | null; project: string; dryRun: boolean; + config: string | null; + saveConfig: boolean; } async function deploy(opts: DeployOpts): Promise { @@ -127,16 +136,19 @@ async function deploy(opts: DeployOpts): Promise { const project = await api.getProject(opts.project); if (project === null) { projectSpinner.fail("Project not found."); - Deno.exit(1); + return Deno.exit(1); } + // opts.project is persisted in deno.json. We want to store the project id even if user provided + // project name to facilitate project renaming. + opts.project = project.id; - const deploymentsListing = await api.getDeployments(project!.id); + const deploymentsListing = await api.getDeployments(project.id); if (deploymentsListing === null) { projectSpinner.fail("Project deployments details not found."); Deno.exit(1); } const [projectDeployments, _pagination] = deploymentsListing!; - projectSpinner.succeed(`Project: ${project!.name}`); + projectSpinner.succeed(`Project: ${project.name}`); if (projectDeployments.length === 0) { wait("").start().info( @@ -252,6 +264,7 @@ async function deploy(opts: DeployOpts): Promise { case "success": { const deploymentKind = opts.prod ? "Production" : "Preview"; deploySpinner!.succeed(`${deploymentKind} deployment complete.`); + await configFile.maybeWrite(opts.config, opts, opts.saveConfig) console.log("\nView at:"); for (const { domain } of event.domainMappings) { console.log(` - https://${domain}`);