UNPKG

@cdwr/nx-fly-deployment-action

Version:

The Nx Fly Deployment Action will manage your deployments to fly.io.

1,677 lines (1,623 loc) 62.4 kB
// packages/nx-fly-deployment-action/src/lib/fly-deployment.ts import * as core7 from "@actions/core"; import * as github5 from "@actions/github"; // packages/core/src/lib/actions/add-pull-request-comment.ts import * as github from "@actions/github"; // packages/core/src/lib/actions/with-github.ts import { RequestError } from "@octokit/request-error"; import { StatusCodes, getReasonPhrase } from "http-status-codes"; async function withGitHub(operation, option) { try { return await operation(); } catch (error2) { if (error2 instanceof RequestError) { switch (error2.status) { case StatusCodes.UNAUTHORIZED: throw new Error( `Authentication failed. Please check your GitHub token. ${error2.message}` ); case StatusCodes.FORBIDDEN: throw new Error( `Permission to the operation was denied. Please check your GitHub token. ${error2.message}` ); case StatusCodes.NOT_FOUND: if (option === "not-found-returns-null") { return null; } throw new Error( `Requested information was not found. ${error2.message}` ); default: throw new Error( `Request failed with ${error2.status} - ${getReasonPhrase(error2.status)}. ${error2.message}` ); } } throw error2; } } // packages/core/src/lib/actions/add-pull-request-comment.ts var addPullRequestComment = async (token, pullRequest, comment) => { const octokit = github.getOctokit(token); const { data: { id } } = await withGitHub( () => octokit.rest.issues.createComment({ ...github.context.repo, issue_number: pullRequest, body: comment }) ); return id; }; // packages/core/src/lib/actions/get-repository-default-branch.ts import * as github2 from "@actions/github"; var getRepositoryDefaultBranch = async (token) => { const octokit = github2.getOctokit(token); const { data: { default_branch } } = await withGitHub( async () => octokit.rest.repos.get({ ...github2.context.repo }) ); return default_branch; }; // packages/core/src/lib/actions/get-deploy-env.ts import { z } from "zod"; var EnvironmentSchema = z.enum(["preview", "production"]); var getDeployEnv = (context6, mainBranch) => { const { eventName, ref } = context6; const currentBranch = ref.split("/").at(-1); if (eventName === "pull_request") { return { environment: "preview" }; } if (eventName === "push") { if (currentBranch === mainBranch) { return { environment: "production" }; } return { environment: null, reason: `'${currentBranch}' is not supported for production deployment, only '${mainBranch}' is supported` }; } return { environment: null, reason: `'${eventName}' is not a supported event for deployment, only 'pull_request' and 'push' are supported` }; }; // packages/core/src/lib/actions/get-pull-request.ts import * as core from "@actions/core"; import * as github3 from "@actions/github"; var getPullRequest = async (token, pullRequest) => { const octokit = github3.getOctokit(token); core.debug(`Get pull request from number #${pullRequest}`); const pr = await withGitHub( () => octokit.rest.pulls.get({ ...github3.context.repo, pull_number: pullRequest }), "not-found-returns-null" ); if (!pr) { core.debug(`Pull request #${pullRequest} could not be found`); return void 0; } return pr.data; }; // packages/core/src/lib/actions/print-github-context.ts import * as core2 from "@actions/core"; import * as github4 from "@actions/github"; var printGitHubContext = () => { core2.info("== Repo =="); core2.info(`- owner: ${github4.context.repo.owner}`); core2.info(`- repo: ${github4.context.repo.repo}`); core2.info("== Misc =="); for (const [key, value] of Object.entries(github4.context)) { if (typeof value === "string") { core2.info(`- ${key}: ${value}`); } } }; // packages/fly-node/src/lib/fly.class.ts import { existsSync } from "fs"; import { join } from "path"; import { cwd } from "process"; // packages/core/src/lib/utils/docker-build.ts import { dockerCommand } from "docker-cli-js"; import invariant from "tiny-invariant"; // packages/core/src/lib/utils/log-utils.ts import chalk from "chalk"; // packages/core/src/lib/utils/promisified-exec.ts import { exec as cpExec, execFile as cpExecFile } from "child_process"; import { promisify } from "util"; var exec = promisify(cpExec); var execFile = promisify(cpExecFile); // packages/core/src/lib/utils/kill-port.ts import kill from "kill-port"; import { check as portCheck } from "tcp-port-used"; // packages/core/src/lib/utils/kill-process-tree.ts import { promisify as promisify2 } from "util"; import treeKill from "tree-kill"; var killProcessTree = promisify2(treeKill); // packages/core/src/lib/utils/promisified-spawn.ts import { spawn as cpSpawn } from "child_process"; function spawn(command, args, options) { return new Promise((resolve, reject) => { const process2 = cpSpawn(command, args, { ...options, stdio: "pipe" }); const stdoutChunks = []; const stderrChunks = []; process2.stdout.on("data", (data) => { stdoutChunks.push(Buffer.from(data)); }); process2.stderr.on("data", (data) => { stderrChunks.push(Buffer.from(data)); }); process2.on("close", (code) => { const stdout = Buffer.concat(stdoutChunks).toString(); const stderr = Buffer.concat(stderrChunks).toString(); if (code === 0) { resolve({ stdout, stderr }); } else { reject(new Error(`Process exited with code ${code} Error: ${stderr}`)); } }); process2.on("error", reject); }); } // packages/core/src/lib/utils/run-command.ts import { tmpProjPath } from "@nx/plugin/testing"; // packages/core/src/lib/utils/spawn-pty.ts import { spawn as spawn2 } from "@homebridge/node-pty-prebuilt-multiarch"; function spawnPty(command, args, options) { return new Promise((resolve, reject) => { const ptyData = []; const ptyProcess = spawn2(command, args, { cwd: process.cwd(), encoding: "utf-8", env: process.env }); ptyProcess.onData((data) => { ptyData.push(data); if (options?.prompt) { const answer = options.prompt(data); if (answer) { ptyProcess.write(`${answer} `); } } }); ptyProcess.onExit(({ exitCode }) => { const output = ptyData.join(""); if (exitCode === 0) { resolve(output); } else { reject( new Error(`Process exited with code ${exitCode} Output: ${output}`) ); } }); }); } // packages/core/src/lib/utils/whoami.ts import npmWhoami from "npm-whoami"; // packages/fly-node/src/lib/fly.class.ts import { ZodError } from "zod"; // packages/core/src/lib/zod/json.schema.ts import { z as z2 } from "zod"; var LiteralSchema = z2.union([z2.string(), z2.number(), z2.boolean(), z2.null()]); var JsonSchema = z2.lazy( () => z2.union([LiteralSchema, z2.array(JsonSchema), z2.record(JsonSchema)]) ); // packages/core/src/lib/zod/with-camel-case.preprocess.ts import { z as z3 } from "zod"; var toCamelCase = (str, specialCases = {}) => { if (specialCases[str]) { return specialCases[str]; } if (str.includes("_")) { return str.toLowerCase().replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()); } if (str === str.toUpperCase()) { return str.toLowerCase(); } return str.charAt(0).toLowerCase() + str.slice(1); }; var transformKeys = (options) => { const { currentPath, data, preserve, specialCases } = options; if (Array.isArray(data)) { return data.map( (item) => transformKeys({ currentPath, data: item, preserve, specialCases }) ); } if (data && typeof data === "object" && data !== null) { return Object.entries(data).reduce((acc, [key, value]) => { const shouldPreserveValue = preserve.some( (path) => [...currentPath, key].join(".").match(new RegExp(`^${path}.`)) ); const newKey = shouldPreserveValue ? key : toCamelCase(key, specialCases); const newPath = [...currentPath, newKey]; return { ...acc, [newKey]: transformKeys({ currentPath: newPath, data: value, preserve, specialCases }) }; }, {}); } return data; }; var withCamelCase = (schema, options = {}) => { const { preserve = [], specialCases = {} } = options; return z3.preprocess( (data) => transformKeys({ currentPath: [], data, preserve, specialCases }), schema ); }; // packages/core/src/lib/zod/with-replace-all.preprocess.ts import { z as z4 } from "zod"; // packages/fly-node/src/lib/schemas/apps-create.schema.ts import { z as z5 } from "zod"; var AppsCreateTransformedResponseSchema = withCamelCase( z5.object({ id: z5.string(), name: z5.string(), organization: z5.object({ id: z5.string(), name: z5.string() }) }) ); // packages/fly-node/src/lib/schemas/apps-list.schema.ts import { z as z6 } from "zod"; var AppsListTransformedResponseSchema = z6.array( withCamelCase( z6.object({ id: z6.string(), name: z6.string(), status: z6.string(), deployed: z6.boolean(), hostname: z6.string(), organization: z6.object({ name: z6.string(), slug: z6.string() }), currentRelease: z6.nullable( z6.object({ status: z6.string(), createdAt: z6.string().datetime() }) ) }) ) ); // packages/fly-node/src/lib/schemas/certs-list-with-app.schema.ts import { z as z8 } from "zod"; // packages/fly-node/src/lib/schemas/certs-list.schema.ts import { z as z7 } from "zod"; var CertsListFlyResponseElementSchema = z7.object({ createdAt: z7.string().datetime(), hostname: z7.string(), clientStatus: z7.string() }); var CertsListTransformedResponseSchema = z7.array( withCamelCase(CertsListFlyResponseElementSchema) ); // packages/fly-node/src/lib/schemas/certs-list-with-app.schema.ts var CertsListWithAppTransformedResponseSchema = z8.array( withCamelCase( CertsListFlyResponseElementSchema.extend({ app: z8.string() }) ) ); // packages/fly-node/src/lib/schemas/config-show.schema.ts import { z as z9 } from "zod"; var ConfigShowResponseSchema = withCamelCase( z9.object({ app: z9.string(), primaryRegion: z9.string().optional(), build: z9.record(z9.string(), z9.any()).optional().or( z9.object({ args: z9.record(z9.string(), z9.string()).optional(), dockerfile: z9.string().optional(), image: z9.string().optional() }) ), deploy: z9.record(z9.string(), z9.any()).optional().or(z9.object({ strategy: z9.string().optional() })), env: z9.record(z9.string(), z9.string()).optional(), httpService: z9.record(z9.string(), z9.any()).optional(), mounts: z9.record(z9.string(), z9.any()).optional(), services: z9.array(z9.record(z9.string(), z9.any())).optional() }), { preserve: ["build.args", "env"] } ); // packages/fly-node/src/lib/schemas/deploy.schema.ts import { z as z10 } from "zod"; var DeployResponseSchema = z10.object({ app: z10.string(), hostname: z10.string(), url: z10.string().url() }); // packages/fly-node/src/lib/schemas/helper-schemas.ts import { z as z11 } from "zod"; var AllowEmptyUrlSchema = z11.string().url().or(z11.literal("")); var NameSchema = z11.string({ message: "App name must be a valid slug" }).regex(/^[a-z0-9-]+$/); // packages/fly-node/src/lib/schemas/postgres-list.ts import { z as z12 } from "zod"; var PostgresListTransformedResponseSchema = z12.array( withCamelCase( z12.object({ deployed: z12.boolean(), hostname: z12.string(), id: z12.string(), name: z12.string(), organization: z12.object({ id: z12.string(), slug: z12.string() }), status: z12.string(), version: z12.number() }) ) ); // packages/fly-node/src/lib/schemas/postgres-users-list.ts import { z as z13 } from "zod"; var PostgresUsersListTransformedResponseSchema = z13.array( withCamelCase( z13.object({ username: z13.string(), superuser: z13.boolean(), databases: z13.array(z13.string()) }) ) ); // packages/fly-node/src/lib/schemas/secrets-list-with-app.schema.ts import { z as z15 } from "zod"; // packages/fly-node/src/lib/schemas/secrets-list.schema.ts import { z as z14 } from "zod"; var SecretsListFlyResponseElementSchema = z14.object({ name: z14.string(), digest: z14.string(), createdAt: z14.string().datetime() }); var SecretsListTransformedResponseSchema = z14.array( withCamelCase(SecretsListFlyResponseElementSchema) ); // packages/fly-node/src/lib/schemas/secrets-list-with-app.schema.ts var SecretsListWithAppTransformedResponseSchema = z15.array( withCamelCase( SecretsListFlyResponseElementSchema.extend({ app: z15.string() }) ) ); // packages/fly-node/src/lib/schemas/status-extended.schema.ts import { z as z17 } from "zod"; // packages/fly-node/src/lib/schemas/status.schema.ts import { z as z16 } from "zod"; var statusFlyResponseSchema = z16.object({ deployed: z16.boolean(), hostname: z16.string(), id: z16.string(), machines: z16.array( z16.object({ id: z16.string(), name: z16.string(), state: z16.string(), region: z16.string(), createdAt: z16.string().datetime(), updatedAt: z16.string().datetime(), config: z16.object({ env: z16.record(z16.string()), metadata: z16.record(z16.string()) }), events: z16.array( z16.object({ type: z16.string(), status: z16.string(), timestamp: z16.number() }) ), checks: z16.array( z16.object({ name: z16.string(), status: z16.string(), output: z16.string(), updatedAt: z16.string().datetime() }) ).optional(), hostStatus: z16.string() }) ), name: z16.string(), organization: z16.object({ id: z16.string(), slug: z16.string() }), status: z16.string(), version: z16.number() }); var StatusTransformedResponseSchema = withCamelCase( statusFlyResponseSchema, { preserve: ["machines.config.env", "machines.config.metadata"], specialCases: { AppURL: "appUrl" } } ); // packages/fly-node/src/lib/schemas/status-extended.schema.ts var StatusExtendedTransformedResponseSchema = withCamelCase( statusFlyResponseSchema.extend({ domains: z17.array(z17.object({ hostname: z17.string() })), secrets: z17.array(z17.object({ name: z17.string() })) }), { preserve: ["machines.config.env", "machines.config.metadata"] } ); // packages/fly-node/src/lib/fly.class.ts var Fly = class { /** Use underscore to avoid conflict with public `config` property */ _config; /** * Whether a user is authenticated via local login. * In this case a token is not needed, though it can still have been provided. */ authByLogin = false; initialized = false; logger; /** * Get the app name from the instance config */ get instanceApp() { if ("app" in this._config) { return this._config.app ?? ""; } return ""; } /** * Get the config file path from the instance config */ get instanceConfig() { if ("config" in this._config) { return this._config.config ?? ""; } return ""; } constructor(config) { this._config = config || {}; this.logger = { info: config?.logger?.info || console.log, error: config?.logger?.error || console.error, traceCLI: config?.logger?.traceCLI || false }; if (!this._config.token) { this._config.token = process.env["FLY_API_TOKEN"] || ""; } } /** * Verify that the Fly client is ready to use. * * This means that the Fly CLI is installed and authenticated to Fly.io. * * Use as ad-hoc check when needed instead of waiting for the first Fly command to run. * * @param mode - Whether to use assertion mode instead of returning `false` * @returns `true` if the Fly client is ready, `false` otherwise * @throws An error in assertion mode if the Fly client is not ready */ async isReady(mode) { try { await this.ensureInitialized(); return true; } catch (error2) { if (mode === "assert") { throw error2; } this.logger.info(`Fly client is not ready ${error2}`); return false; } } /** * Manage the Fly CLI tool */ cli = { /** * Check if the Fly CLI tool is installed * * @returns `true` if the Fly CLI tool is installed, `false` otherwise */ isInstalled: async () => await this.isInstalled() }; // // External API following the `flyctl` CLI commands structure // /** * Manage apps */ apps = { /** * Create a new application with a generated name when not provided. * * @param options - Options for creating an application * @returns The name of the created application * @throws An error if the application cannot be created */ create: async (options) => { try { await this.ensureInitialized(); const { name } = await this.createApp(options); this.logger.info(`Application '${name}' was created`); return name; } catch (error2) { throw new Error( `[create app] something broke, check your apps ${error2}` ); } }, /** * Destroy an application and make sure it gets detached from any Postgres clusters * * @param app - The name of the app to destroy * @param options - Options for destroying an application * @throws An error if the app cannot be destroyed */ destroy: async (app, options) => { try { await this.ensureInitialized(); const attachedClusters = await this.getAttachedPostgresClusters(app); for (const cluster of attachedClusters) { this.logger.info( `Detaching Postgres cluster '${cluster}' from application '${app}'` ); const output = await this.detachPostgres(cluster, app); this.logger.info(output); } await this.destroyApp(app, options?.force); this.logger.info(`Application '${app}' was destroyed`); } catch (error2) { throw new Error( `[destroy app] something broke, check your apps ${error2}` ); } }, /** * List all applications * * @returns A list of applications * @throws An error if listing applications fails */ list: async () => { try { return await this.fetchAllApps("throwOnError"); } catch (error2) { throw new Error(`[list] something broke ${error2}`); } } }; /** * Manage certificates */ certs = { /** * Add a certificate for a domain to an application * * @param hostname - The hostname to add a certificate for * @param options - Options for adding a certificate * @throws An error if the certificate cannot be added */ add: async (hostname, options) => { try { await this.ensureInitialized(); const appName = await this.addAppCertificate(hostname, options); this.logger.info( `Certificate for domain '${hostname}' was added to '${appName}'` ); } catch (error2) { throw new Error( `[add certificate] something broke, check your app certificates ${error2}` ); } }, /** * List certificates for an application * * @param options - List certificates for an application or all certificates * @returns A list of certificates * @throws An error if listing certificates fails */ list: async (options) => { try { await this.ensureInitialized(); const result = options === "all" ? await this.fetchAllCerts("throwOnError") : await this.fetchAppCerts("throwOnError", options); return result; } catch (error2) { throw new Error(`[list certificates] something broke ${error2}`); } }, /** * Remove a certificate for a domain from an application * * @param hostname - The hostname to remove a certificate for * @param options - Options for removing a certificate * @throws An error if the certificate cannot be removed */ remove: async (hostname, options) => { try { await this.ensureInitialized(); const appName = await this.removeAppCertificate(hostname, options); this.logger.info( `Certificate for domain '${hostname}' was removed from '${appName}'` ); } catch (error2) { throw new Error( `[remove certificate] something broke, check your app certificates ${error2}` ); } } }; /** * Manage an app's configuration */ config = { /** * Show an application's configuration. * * @param options - Options for showing an application's configuration * @returns The application's JSON configuration * @throws An error if the configuration cannot be found or parsed */ show: async (options) => { try { await this.ensureInitialized(); return await this.showConfig("throwOnError", options); } catch (error2) { throw new Error(`[show config] something broke ${error2}`); } } }; /** * Deploy an application. * * A valid config file is required but the app doesn't have to exist, * since it gets created if needed. * * The app name is selected from: * 1. Provided app name * 2. Instance app name * 3. App name from the provided config file * * Existing secrets are preserved. * * @param options - Options for deploying an application * @throws An error if the deployment fails */ deploy = async (options) => { try { await this.ensureInitialized(); const appName = await this.deployApp(options); this.logger.info(`Application '${appName}' was deployed`); const status = await this.fetchAppStatus("throwOnError", { app: appName }); return DeployResponseSchema.parse({ app: status.name, hostname: status.hostname, url: `https://${status.hostname}` }); } catch (error2) { throw new Error( `[deploy] something broke, check your deployments ${error2}` ); } }; postgres = { detach: async (cluster, app) => { await this.detachPostgres(cluster, app); } }; /** * Manage application secrets */ secrets = { /** * Set secrets for an application * * @param secrets - The secrets to set * @param options - Options for setting secrets * @throws An error if the secrets cannot be set */ set: async (secrets, options) => { try { await this.ensureInitialized(); const appName = await this.setAppSecrets(secrets, options); const keys = Object.keys(secrets); this.logger.info( `Secrets were set for '${appName}': ${keys.join(", ")}` ); } catch (error2) { throw new Error( `[set secrets] something broke, check your app secrets ${error2}` ); } }, /** * List secrets for an application * * @param options - List secrets for an application or all secrets * @returns A list of secrets * @throws An error if listing secrets fails */ list: async (options) => { try { await this.ensureInitialized(); const result = options === "all" ? await this.fetchAllSecrets("throwOnError") : await this.fetchAppSecrets("throwOnError", options); return result; } catch (error2) { throw new Error(`[list secrets] something broke ${error2}`); } }, /** * Unset secrets for an application * * @param keys - The keys to unset * @param options - Options for unsetting secrets * @throws An error if the secrets cannot be unset */ unset: async (keys, options) => { try { await this.ensureInitialized(); const keysArray = Array.isArray(keys) ? keys : [keys]; const appName = await this.unsetAppSecrets(keysArray, options); this.logger.info( `Secrets were unset for '${appName}': ${keysArray.join(", ")}` ); } catch (error2) { throw new Error( `[unset secrets] something broke, check your app secrets ${error2}` ); } } }; /** * Get the status details of an application * * @param options - Options for getting app status * @returns The app status, or `null` if an app could not be found */ status = async (options) => { try { await this.ensureInitialized(); const status = await this.fetchAppStatus("nullOnError", options); if (status === null) { return null; } return status; } catch (error2) { this.logger.info(`[status] something broke ${error2}`); return null; } }; /** * Get the extended details of an application * * @param options - Options for getting app details * @returns The app details, or `null` if an app could not be found */ statusExtended = async (options) => { try { await this.ensureInitialized(); const status = await this.fetchAppStatus("nullOnError", options); if (status === null) { return null; } const certs = await this.fetchAppCerts("nullOnError", options) || []; const secrets = await this.fetchAppSecrets("nullOnError", options) || []; return StatusExtendedTransformedResponseSchema.parse({ ...status, domains: certs.map(({ hostname }) => ({ hostname })), secrets: secrets.map(({ name }) => ({ name })) }); } catch (error2) { this.logger.info(`[status extended] something broke ${error2}`); return null; } }; // // Private methods // /** * @private * A name will be generated when not provided. * @returns The name of the created application * @throws An error if the application cannot be created */ async createApp(options) { const args = ["apps", "create"]; args.push(options?.app || "--generate-name"); args.push("--org", options?.org || this._config.org || "personal"); args.push("--json"); return AppsCreateTransformedResponseSchema.parseAsync( await this.execFly(args) ); } /** * @private * @returns The name of the deployed application * @throws An error if the deployment fails */ async deployApp(options) { let appName = ""; let appSecrets = []; let lookupApp = options?.app || this.instanceApp; const lookupConfig = options?.config || this.instanceConfig; if (!lookupConfig) { throw new Error( "Fly config file must be provided to options or class instance, unable to deploy" ); } const appConfig = await this.showConfig("nullOnError", { config: lookupConfig, local: true }); if (!appConfig) { throw new Error( `Fly config file '${lookupConfig}' does not exist, unable to deploy` ); } if (!lookupApp) { lookupApp = appConfig.app; } if (lookupApp) { const status = await this.fetchAppStatus("nullOnError", { app: lookupApp }); if (status) { this.logger.info( `Found existing application '${lookupApp}' as target for deployment` ); appName = status.name; } else { this.logger.info( `Application '${lookupApp}' not found, try to create a new one with this name` ); } } if (!appName) { this.logger.info("Creating a new application..."); const { name } = await this.createApp({ app: lookupApp, org: options?.org }); appName = name; this.logger.info(`Application '${appName}' was created`); } else { appSecrets = await this.fetchAppSecrets("throwOnError", { app: appName }); this.logger.info(`Updating existing application '${appName}'`); } NameSchema.parse(appName); if (options?.secrets) { const existingSecrets = new Set(appSecrets.map(({ name }) => name)); const newSecrets = {}; for (const [key, value] of Object.entries(options.secrets)) { if (!existingSecrets.has(key)) { newSecrets[key] = value; } else { this.logger.info( `Secret '${key}' already exists for '${appName}', skipping` ); } } const keys = Object.keys(newSecrets); if (keys.length > 0) { this.logger.info(`Adding secrets to '${appName}': ${keys.join(", ")}`); await this.setAppSecrets(newSecrets, { app: appName, stage: true }); } } if (options?.app && options.app !== appName) { this.logger.info( `Using provided app name '${options.app}' instead of '${appName}'` ); appName = options.app; } if (options?.postgres) { const users = await this.fetchPostgresUsers( options.postgres, "nullOnError" ); if (!users) { this.logger.error( `Failed to fetch users for Postgres cluster '${options.postgres}', unable to attach '${appName}'` ); } else if (users.find((user) => user.username === appName.replace(/-/g, "_"))) { this.logger.info( `Postgres cluster '${options.postgres}' already attached to '${appName}', skipping` ); } else { this.logger.info( `Attach Postgres cluster '${options.postgres}' to '${appName}'` ); await this.attachPostgres(options.postgres, appName); } } const args = ["deploy", "--app", appName, "--config", lookupConfig]; if (options?.region) { args.push("--region", options.region); } if (options?.environment) { args.push("--env", `DEPLOY_ENV=${options.environment}`); } for (const [key, value] of Object.entries(options?.env || {})) { args.push("--env", `${key}=${this.safeArg(value)}`); } if (options?.optOutDepotBuilder) { args.push("--depot=false"); } args.push("--yes"); this.logger.info(`Deploying '${appName}'...`); await this.execFly(args); return appName; } /** * @private * @throws An error if the Postgres database cannot be attached */ async attachPostgres(postgres, app) { const args = ["postgres", "attach", postgres, "--app", app, "--yes"]; await this.execFly(args); } /** * @private * @throws An error if the app cannot be destroyed */ async destroyApp(app, force) { const args = ["apps", "destroy", app, "--yes"]; if (force) args.push("--force"); await this.execFly(args); } /** * @private * @returns Log output from the detach process * @throws An error if the Postgres database cannot be detached from an app */ async detachPostgres(postgres, app) { const args = ["postgres", "detach", postgres, "--app", app]; return await this.execFly(args, { prompt: (output) => { if (output.match(/select .* detach/i)) { return app.replace(/-/g, "_"); } return; } }); } /** * @private * @returns The name of the application * @throws An error if the secrets cannot be set */ async setAppSecrets(secrets, options) { const status = await this.fetchAppStatus("throwOnError", options); const args = ["secrets", "set"]; args.push(...this.getAppOrConfigArgs(options)); if (options?.stage) { args.push("--stage"); } for (const [key, value] of Object.entries(secrets)) { args.push(`${key}=${this.safeArg(value)}`); } await this.execFly(args); return NameSchema.parse(status.name); } /** * @private * @returns The name of the application * @throws An error if the secrets cannot be unset */ async unsetAppSecrets(keys, options) { const status = await this.fetchAppStatus("throwOnError", options); const args = ["secrets", "unset"]; args.push(...this.getAppOrConfigArgs(options)); if (options?.stage) { args.push("--stage"); } args.push(...keys); await this.execFly(args); return NameSchema.parse(status.name); } /** * @private * @returns The name of the application * @throws An error if the domain cannot be added */ async addAppCertificate(hostname, options) { const status = await this.fetchAppStatus("throwOnError", options); const args = ["certs", "add", hostname]; args.push(...this.getAppOrConfigArgs(options)); args.push("--json"); await this.execFly(args); return NameSchema.parse(status.name); } /** * @private * @returns The name of the application * @throws An error if the domain cannot be removed */ async removeAppCertificate(hostname, options) { const status = await this.fetchAppStatus("throwOnError", options); const args = ["certs", "remove", hostname]; args.push(...this.getAppOrConfigArgs(options)); args.push("--yes"); await this.execFly(args); return NameSchema.parse(status.name); } async fetchAllApps(onError) { const args = ["apps", "list", "--json"]; try { return AppsListTransformedResponseSchema.parseAsync( await this.execFly(args) ); } catch (error2) { if (onError === "throwOnError") { throw error2; } return null; } } async fetchAllCerts(onError) { try { const certs = []; const apps = await this.fetchAllApps("throwOnError"); for (const app of apps) { const appCerts = await this.fetchAppCerts("throwOnError", { app: app.name }); certs.push(...appCerts.map((cert) => ({ ...cert, app: app.name }))); } return CertsListWithAppTransformedResponseSchema.parse(certs); } catch (error2) { if (onError === "throwOnError") { throw error2; } return null; } } async fetchAllPostgres(onError) { const args = ["postgres", "list", "--json"]; try { const response = await this.execFly(args); return PostgresListTransformedResponseSchema.parse(response); } catch (error2) { if (onError === "throwOnError") { throw error2; } return null; } } async fetchAllSecrets(onError) { try { const secrets = []; const apps = await this.fetchAllApps("throwOnError"); for (const app of apps) { const appSecrets = await this.fetchAppSecrets("throwOnError", { app: app.name }); secrets.push( ...appSecrets.map((secret) => ({ ...secret, app: app.name })) ); } return SecretsListWithAppTransformedResponseSchema.parse(secrets); } catch (error2) { if (onError === "throwOnError") { throw error2; } return null; } } async fetchAppStatus(onError, options) { const args = ["status"]; args.push(...this.getAppOrConfigArgs(options)); args.push("--json"); let response = null; try { response = await this.execFly(args); return StatusTransformedResponseSchema.parse(response); } catch (error2) { if (onError === "throwOnError") { throw error2; } if (error2 instanceof ZodError) { this.logger.info(`Failed to parse response: ${error2.message}`); this.logger.info(`Raw response: ${JSON.stringify(response, null, 2)}`); } return null; } } async fetchAppCerts(onError, options) { const args = ["certs", "list"]; args.push(...this.getAppOrConfigArgs(options)); args.push("--json"); try { return CertsListTransformedResponseSchema.parseAsync( await this.execFly(args) ); } catch (error2) { if (onError === "throwOnError") { throw error2; } return null; } } async fetchAppSecrets(onError, options) { const args = ["secrets", "list"]; args.push(...this.getAppOrConfigArgs(options)); args.push("--json"); try { return SecretsListTransformedResponseSchema.parseAsync( await this.execFly(args) ); } catch (error2) { if (onError === "throwOnError") { throw error2; } return null; } } async fetchPostgresUsers(postgresApp, onError) { const args = ["postgres", "users", "list", "--app", postgresApp, "--json"]; try { const users = await this.execFly(args); return PostgresUsersListTransformedResponseSchema.parse(users); } catch (error2) { if (onError === "throwOnError") { throw error2; } return null; } } async showConfig(onError, options) { const normalized = this.normalizeOptions(options); const config = normalized.config || this.instanceConfig; if (config && !existsSync(join(cwd(), config))) { throw new Error(`Config file '${config}' does not exist`); } const args = ["config", "show"]; args.push(...this.getAppOrConfigArgs(options)); if (options && "local" in options && options.local) { args.push("--local"); } try { return ConfigShowResponseSchema.parse(await this.execFly(args)); } catch (error2) { if (onError === "throwOnError") { throw error2; } return null; } } /** * @private * Ensure Fly CLI is installed and authenticated * * @throws An error if Fly CLI is not installed or authentication fails */ async ensureInitialized() { if (this.initialized) return; try { if (!await this.isInstalled()) { throw new Error("Fly CLI must be installed to use this library"); } this.logger.info("Authenticating with Fly.io..."); let user = await this.execFly( ["auth", "whoami"], void 0, "nullOnError", true // won't use token ); if (user) { this.logger.info(`Detected logged in user '${user}'`); this.authByLogin = true; } else { user = await this.execFly(["auth", "whoami"]); this.logger.info(`Authenticated by token as '${user}'`); } this.initialized = true; } catch (error2) { this.logger.error("Failed to initialize Fly"); throw error2; } } async execFly(command, options, onError = "throwOnError", skipToken) { const args = Array.isArray(command) ? command : command.split(" "); if (!skipToken && !this.authByLogin && this._config.token) { args.push("--access-token", this._config.token); } const flyCmd = "flyctl"; const toLogCmd = `${flyCmd} ${args.join(" ")}`; let output = ""; try { this.traceCLI("CALL", toLogCmd); if (options?.prompt) { output = await spawnPty(flyCmd, args, { prompt: options.prompt }); this.traceCLI("RESULT", output); } else { const result = await spawn(flyCmd, args, options); output = result.stdout.trim(); const stderr = result.stderr.trim(); this.traceCLI("RESULT", `${output}${stderr ? ` ${stderr}` : ""}`); } } catch (error2) { const errorMsg = error2.message; this.traceCLI("RESULT", errorMsg); if (onError === "throwOnError") { throw new Error(`Command failed: ${toLogCmd} ${errorMsg}`); } return null; } try { return JSON.parse(output); } catch { if (output) { if (this.logger.traceCLI) { this.logger.info(`Failed to parse as JSON, returning raw output`); } return output; } } return void 0; } /** * @private * Get the arguments for `AppOrConfig` options * where either the application or configuration can be provided * but not both. * * First check `options` and then the class configuration. * * Should be used in Fly commands with optional flags * - `--app` * - `--config` * * @param options - Options to get the arguments for * @returns The arguments to use in a fly command */ getAppOrConfigArgs(options) { const normalized = this.normalizeOptions(options); const app = normalized.app || this.instanceApp; const config = normalized.config || this.instanceConfig; const args = []; if (app) { args.push("--app", app); } if (config) { args.push("--config", config); } if (app && config) { this.logger.info( `Both app '${app}' and config '${config}' were provided, check your implementation!` ); } return args; } /** * @private * Get the Postgres clusters attached to an app * * @param app - The app to get the attached Postgres clusters for * @returns The attached Postgres clusters * @throws An error if the Postgres clusters cannot be retrieved */ async getAttachedPostgresClusters(app) { const attached = /* @__PURE__ */ new Set(); const postgres = await this.fetchAllPostgres("throwOnError"); for (const { name } of postgres) { const users = await this.fetchPostgresUsers(name, "nullOnError"); if (!users) { this.logger.info( `Failed to fetch users for Postgres cluster '${name}', ignoring` ); continue; } if (users.some((user) => user.username === app.replace(/-/g, "_"))) { attached.add(name); } } return Array.from(attached); } /** * @private * Check if the Fly CLI is installed * * @returns `true` if the Fly CLI is installed, `false` otherwise */ async isInstalled() { return await this.execFly("version", void 0, "nullOnError") !== null; } /** * @private * Normalize the options matching `AppOrConfig` type. * * A missing value gets an empty string. * * @param options - The options to normalize * @returns The normalized options */ normalizeOptions(options) { if (!options) { return { app: "", config: "" }; } const app = "app" in options && options.app ? options.app : ""; const config = "config" in options && options.config ? options.config : ""; return { app, config }; } /** * Escape an argument for Fly cli * * @param arg - The argument to escape * @returns The escaped argument */ safeArg(arg) { return arg.replace(/\\/g, "\\\\").replace(/ /g, "\\ "); } /** * @private * Trace the CLI calls, mocks and results * * @param type - The type of trace * @param msg - The message to trace */ traceCLI(type, msg) { if (this.logger.traceCLI) { const typeLabel = type === "CALL" ? " CALL " : type === "MOCK" ? " MOCK " : "RESULT"; this.logger.info(`[${typeLabel}] ${msg}`); } } }; // packages/nx-fly-deployment-action/src/lib/schemas/action-outputs.schema.ts import { z as z18 } from "zod"; var ActionSchema = z18.enum(["deploy", "destroy", "skip"]); var FlyAppNameSchema = z18.object({ app: z18.string({ description: "App name" }).min(1, "An app name is required") }); var ActionDeploySchema = FlyAppNameSchema.merge( z18.object({ action: z18.literal(ActionSchema.enum.deploy), name: z18.string({ description: "Project name" }).min(1, "A project name is required"), url: z18.string({ description: "Deployment URL" }).min(1, "A deployment URL is required") }) ); var ActionDestroySchema = FlyAppNameSchema.merge( z18.object({ action: z18.literal(ActionSchema.enum.destroy) }) ); var ActionSkipSchema = z18.object({ action: z18.literal(ActionSchema.enum.skip), appOrProject: z18.string({ description: "App or project name" }).min(1, "An app or project name is required"), reason: z18.string({ description: "Reason for skipping" }).min(1, "A reason is required") }); var ProjectSchema = z18.discriminatedUnion("action", [ ActionDeploySchema, ActionDestroySchema, ActionSkipSchema ]); var ActionOutputsSchema = z18.object({ environment: EnvironmentSchema.or(z18.null()), projects: z18.array(ProjectSchema) }); // packages/nx-fly-deployment-action/src/lib/schemas/context.schema.ts import { z as z19 } from "zod"; var BasePreviewSchema = z19.object({ environment: z19.literal(EnvironmentSchema.enum.preview), pullRequest: z19.number({ description: "Pull request number", required_error: "A pull request number is required" }) }); var BaseProductionSchema = z19.object({ environment: z19.literal(EnvironmentSchema.enum.production) }); var ContextSchema = z19.discriminatedUnion("environment", [ BasePreviewSchema.merge( z19.object({ action: z19.enum([ActionSchema.enum.deploy, ActionSchema.enum.destroy]) }) ), BaseProductionSchema.merge( z19.object({ action: z19.enum([ActionSchema.enum.deploy]) }) ) ]); // packages/nx-fly-deployment-action/src/lib/utils/get-deployment-config.ts import * as core3 from "@actions/core"; // packages/nx-fly-deployment-action/src/lib/schemas/deployment-config.schema.ts import { z as z21 } from "zod"; // packages/nx-fly-deployment-action/src/lib/schemas/action-inputs.schema.ts import { z as z20 } from "zod"; var ActionInputsSchema = z20.object({ env: z20.array(z20.string()), flyApiToken: z20.string(), flyOrg: z20.string(), flyRegion: z20.string(), mainBranch: z20.string(), optOutDepotBuilder: z20.boolean(), secrets: z20.array(z20.string()), token: z20.string().min(1, "A GitHub token is required") }); // packages/nx-fly-deployment-action/src/lib/schemas/deployment-config.schema.ts var DeploymentConfigSchema = z21.object({ env: z21.record(z21.string(), z21.string()).optional(), fly: z21.object({ token: z21.string().min(1, "Fly API token is required"), org: z21.string(), region: z21.string(), optOutDepotBuilder: z21.boolean() }), mainBranch: z21.string().min(1, "main branch is required"), secrets: z21.record(z21.string(), z21.string()).optional(), token: ActionInputsSchema.shape.token }); // packages/nx-fly-deployment-action/src/lib/utils/get-deployment-config.ts var arrayToRecord = (arr) => arr.length ? arr.map((secret) => secret.split("=")).reduce( (acc, [key, value]) => { acc[key] = value; return acc; }, {} ) : void 0; var getDeploymentConfig = async (inputs) => { const { env: envInput, flyApiToken, flyOrg, flyRegion, mainBranch: mainBranchInput, optOutDepotBuilder, secrets: secretsInput, token } = inputs; const mainBranch = mainBranchInput || await getRepositoryDefaultBranch(token); const env = arrayToRecord(envInput); const secrets = arrayToRecord(secretsInput); const config = { env, fly: { token: flyApiToken || process.env["FLY_API_TOKEN"] || "", org: flyOrg, region: flyRegion, optOutDepotBuilder }, mainBranch, secrets, token }; core3.info(JSON.stringify(config, null, 2)); return DeploymentConfigSchema.parse(config); }; // packages/nx-fly-deployment-action/src/lib/utils/run-deploy-apps.ts import { join as join3 } from "path"; import * as core5 from "@actions/core"; // packages/nx-fly-deployment-action/src/lib/utils/add-opinionated-env.ts var addOpinionatedEnv = ({ appName, prNumber }, env) => { return { ...env, APP_NAME: appName, PR_NUMBER: String(prNumber ?? "") }; }; // packages/nx-fly-deployment-action/src/lib/utils/get-deployable-projects.ts import * as core4 from "@actions/core"; import * as exec2 from "@actions/exec"; import { getPackageManagerCommand } from "@nx/devkit"; var getDeployableProjects = async () => { const pmc = getPackageManagerCommand(); core4.info("Getting deployable projects"); const { stdout } = await exec2.getExecOutput(pmc.exec, [ "nx", "show", "projects", "--type", "app", "--affected", "--json" ]); const parsedOutput = JSON.parse(stdout); if (!Array.isArray(parsedOutput)) { throw new Error("Expected output to be an array"); } return parsedOutput; }; // packages/nx-fly-deployment-action/src/lib/utils/get-preview-app-name.ts var getPreviewAppName = (projectName, pullRequest) => { return `${projectName}-pr-${pullRequest}`; }; // packages/nx-fly-deployment-action/src/lib/utils/get-project-configuration.ts import * as exec3 from "@actions/exec"; import { getPackageManagerCommand as getPackageManagerCommand2 } from "@nx/devkit"; var getProjectConfiguration = async (projectName) => { const pmc = getPackageManagerCommand2(); const { stdout } = await exec3.getExecOutput( pmc.exec, ["nx", "show", "project", projectName, "--json"], { silent: true } ); const config = JSON.parse(stdout); if (config.name !== projectName || !config.root) { return null; } return config; }; // packages/nx-fly-deployment-action/src/lib/utils/lookup-github-config-file.ts import { promises, readFileSync } from "fs"; import { basename, join as join2 } from "path"; // libs/shared/util/zod/src/lib/create-lowercase-schema.ts import { z as z22 } from "zod"; // libs/shared/util/zod/src/lib/with-env-vars.preprocess.ts import { z as z23 } from "zod"; var processData = (data, options) => { if (Array.isArray(data)) { return data.map((item) => processData(item, options)); } if (data && typeof data === "object" && data !== null) { return Object.entries(data).reduce((acc, [key, value]) => { return { ...acc, [key]: processData(value, options) }; }, {}); } if (data && typeof data === "string" && data !== null) { const { defaultValue, prefix, throwOnMissing, undefinedIsEmpty } = options; return data.replace(/[\\$]?{([^}]+)}/g, (_, key) => {