@cdwr/fly-node
Version:
The flyctl node wrapper for programmatic deployments to fly.io.
1,335 lines (1,303 loc) • 39.2 kB
JavaScript
// 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
};