UNPKG

@cdwr/fly-node

Version:

The flyctl node wrapper for programmatic deployments to fly.io.

1,335 lines (1,303 loc) 39.2 kB
// 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/get-package-version.ts import { execFile } from "child_process"; import { promisify } from "util"; var execAsync = promisify(execFile); // 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-exec.ts import { exec as cpExec } from "child_process"; import { promisify as promisify3 } from "util"; var exec = promisify3(cpExec); // 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 } from "zod"; var LiteralSchema = z.union([z.string(), z.number(), z.boolean(), z.null()]); var JsonSchema = z.lazy( () => z.union([LiteralSchema, z.array(JsonSchema), z.record(JsonSchema)]) ); // packages/core/src/lib/zod/with-camel-case.preprocess.ts import { z as z2 } 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 z2.preprocess( (data) => transformKeys({ currentPath: [], data, preserve, specialCases }), schema ); }; // packages/core/src/lib/zod/with-replace-all.preprocess.ts import { z as z3 } from "zod"; // packages/fly-node/src/lib/schemas/apps-create.schema.ts import { z as z4 } from "zod"; var AppsCreateTransformedResponseSchema = withCamelCase( z4.object({ id: z4.string(), name: z4.string(), organization: z4.object({ id: z4.string(), name: z4.string() }) }) ); // packages/fly-node/src/lib/schemas/apps-list.schema.ts import { z as z5 } from "zod"; var AppsListTransformedResponseSchema = z5.array( withCamelCase( z5.object({ id: z5.string(), name: z5.string(), status: z5.string(), deployed: z5.boolean(), hostname: z5.string(), organization: z5.object({ name: z5.string(), slug: z5.string() }), currentRelease: z5.nullable( z5.object({ status: z5.string(), createdAt: z5.string().datetime() }) ) }) ) ); // packages/fly-node/src/lib/schemas/certs-list-with-app.schema.ts import { z as z7 } from "zod"; // packages/fly-node/src/lib/schemas/certs-list.schema.ts import { z as z6 } from "zod"; var CertsListFlyResponseElementSchema = z6.object({ createdAt: z6.string().datetime(), hostname: z6.string(), clientStatus: z6.string() }); var CertsListTransformedResponseSchema = z6.array( withCamelCase(CertsListFlyResponseElementSchema) ); // packages/fly-node/src/lib/schemas/certs-list-with-app.schema.ts var CertsListWithAppTransformedResponseSchema = z7.array( withCamelCase( CertsListFlyResponseElementSchema.extend({ app: z7.string() }) ) ); // packages/fly-node/src/lib/schemas/config-show.schema.ts import { z as z8 } from "zod"; var ConfigShowResponseSchema = withCamelCase( z8.object({ app: z8.string(), primaryRegion: z8.string().optional(), build: z8.record(z8.string(), z8.any()).optional().or( z8.object({ args: z8.record(z8.string(), z8.string()).optional(), dockerfile: z8.string().optional(), image: z8.string().optional() }) ), deploy: z8.record(z8.string(), z8.any()).optional().or(z8.object({ strategy: z8.string().optional() })), env: z8.record(z8.string(), z8.string()).optional(), httpService: z8.record(z8.string(), z8.any()).optional(), mounts: z8.record(z8.string(), z8.any()).optional(), services: z8.array(z8.record(z8.string(), z8.any())).optional() }), { preserve: ["build.args", "env"] } ); // packages/fly-node/src/lib/schemas/deploy.schema.ts import { z as z9 } from "zod"; var DeployResponseSchema = z9.object({ app: z9.string(), hostname: z9.string(), url: z9.string().url() }); // packages/fly-node/src/lib/schemas/helper-schemas.ts import { z as z10 } from "zod"; var AllowEmptyUrlSchema = z10.string().url().or(z10.literal("")); var NameSchema = z10.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 z11 } from "zod"; var PostgresListTransformedResponseSchema = z11.array( withCamelCase( z11.object({ deployed: z11.boolean(), hostname: z11.string(), id: z11.string(), name: z11.string(), organization: z11.object({ id: z11.string(), slug: z11.string() }), status: z11.string(), version: z11.number() }) ) ); // packages/fly-node/src/lib/schemas/postgres-users-list.ts import { z as z12 } from "zod"; var PostgresUsersListTransformedResponseSchema = z12.array( withCamelCase( z12.object({ username: z12.string(), superuser: z12.boolean(), databases: z12.array(z12.string()) }) ) ); // packages/fly-node/src/lib/schemas/secrets-list-with-app.schema.ts import { z as z14 } from "zod"; // packages/fly-node/src/lib/schemas/secrets-list.schema.ts import { z as z13 } from "zod"; var SecretsListFlyResponseElementSchema = z13.object({ name: z13.string(), digest: z13.string(), createdAt: z13.string().datetime() }); var SecretsListTransformedResponseSchema = z13.array( withCamelCase(SecretsListFlyResponseElementSchema) ); // packages/fly-node/src/lib/schemas/secrets-list-with-app.schema.ts var SecretsListWithAppTransformedResponseSchema = z14.array( withCamelCase( SecretsListFlyResponseElementSchema.extend({ app: z14.string() }) ) ); // packages/fly-node/src/lib/schemas/status-extended.schema.ts import { z as z16 } from "zod"; // packages/fly-node/src/lib/schemas/status.schema.ts import { z as z15 } from "zod"; var statusFlyResponseSchema = z15.object({ deployed: z15.boolean(), hostname: z15.string(), id: z15.string(), machines: z15.array( z15.object({ id: z15.string(), name: z15.string(), state: z15.string(), region: z15.string(), createdAt: z15.string().datetime(), updatedAt: z15.string().datetime(), config: z15.object({ env: z15.record(z15.string()), metadata: z15.record(z15.string()) }), events: z15.array( z15.object({ type: z15.string(), status: z15.string(), timestamp: z15.number() }) ), checks: z15.array( z15.object({ name: z15.string(), status: z15.string(), output: z15.string(), updatedAt: z15.string().datetime() }) ).optional(), hostStatus: z15.string() }) ), name: z15.string(), organization: z15.object({ id: z15.string(), slug: z15.string() }), status: z15.string(), version: z15.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: z16.array(z16.object({ hostname: z16.string() })), secrets: z16.array(z16.object({ name: z16.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 (error) { if (mode === "assert") { throw error; } this.logger.info(`Fly client is not ready ${error}`); 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 (error) { throw new Error( `[create app] something broke, check your apps ${error}` ); } }, /** * 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 (error) { throw new Error( `[destroy app] something broke, check your apps ${error}` ); } }, /** * List all applications * * @returns A list of applications * @throws An error if listing applications fails */ list: async () => { try { return await this.fetchAllApps("throwOnError"); } catch (error) { throw new Error(`[list] something broke ${error}`); } } }; /** * 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 (error) { throw new Error( `[add certificate] something broke, check your app certificates ${error}` ); } }, /** * 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 (error) { throw new Error(`[list certificates] something broke ${error}`); } }, /** * 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 (error) { throw new Error( `[remove certificate] something broke, check your app certificates ${error}` ); } } }; /** * 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 (error) { throw new Error(`[show config] something broke ${error}`); } } }; /** * 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 (error) { throw new Error( `[deploy] something broke, check your deployments ${error}` ); } }; 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 (error) { throw new Error( `[set secrets] something broke, check your app secrets ${error}` ); } }, /** * 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 (error) { throw new Error(`[list secrets] something broke ${error}`); } }, /** * 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 (error) { throw new Error( `[unset secrets] something broke, check your app secrets ${error}` ); } } }; /** * 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 (error) { this.logger.info(`[status] something broke ${error}`); 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 (error) { this.logger.info(`[status extended] something broke ${error}`); 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 (error) { if (onError === "throwOnError") { throw error; } 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 (error) { if (onError === "throwOnError") { throw error; } return null; } } async fetchAllPostgres(onError) { const args = ["postgres", "list", "--json"]; try { const response = await this.execFly(args); return PostgresListTransformedResponseSchema.parse(response); } catch (error) { if (onError === "throwOnError") { throw error; } 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 (error) { if (onError === "throwOnError") { throw error; } 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 (error) { if (onError === "throwOnError") { throw error; } if (error instanceof ZodError) { this.logger.info(`Failed to parse response: ${error.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 (error) { if (onError === "throwOnError") { throw error; } 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 (error) { if (onError === "throwOnError") { throw error; } 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 (error) { if (onError === "throwOnError") { throw error; } 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 (error) { if (onError === "throwOnError") { throw error; } 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 (error) { this.logger.error("Failed to initialize Fly"); throw error; } } 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 (error) { const errorMsg = error.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}`); } } }; export { Fly };