@cdwr/nx-fly-deployment-action
Version:
The Nx Fly Deployment Action will manage your deployments to fly.io.
1,678 lines (1,624 loc) • 62.5 kB
JavaScript
// packages/nx-fly-deployment-action/src/lib/main.ts
import * as core8 from "@actions/core";
// 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