Merge pull request #248 from Dokploy/245-volumes-are-canceled-on-deployment

Fix(volumes):Make file mounts are persistent
This commit is contained in:
Mauricio Siu 2024-07-21 20:26:53 -06:00 committed by GitHub
commit 701319efdd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 3241 additions and 174 deletions

View File

@ -26,7 +26,7 @@ describe("unzipDrop using real zip files", () => {
it("should correctly extract a zip with a single root folder", async () => {
const appName = "single-file";
const outputPath = path.join(APPLICATIONS_PATH, appName);
const outputPath = path.join(APPLICATIONS_PATH, appName, "code");
const zip = new AdmZip("./__test__/drop/zips/single-file.zip");
const zipBuffer = zip.toBuffer();
@ -39,7 +39,7 @@ describe("unzipDrop using real zip files", () => {
it("should correctly extract a zip with a single root folder and a subfolder", async () => {
const appName = "folderwithfile";
const outputPath = path.join(APPLICATIONS_PATH, appName);
const outputPath = path.join(APPLICATIONS_PATH, appName, "code");
const zip = new AdmZip("./__test__/drop/zips/folder-with-file.zip");
const zipBuffer = zip.toBuffer();
@ -52,7 +52,7 @@ describe("unzipDrop using real zip files", () => {
it("should correctly extract a zip with multiple root folders", async () => {
const appName = "two-folders";
const outputPath = path.join(APPLICATIONS_PATH, appName);
const outputPath = path.join(APPLICATIONS_PATH, appName, "code");
const zip = new AdmZip("./__test__/drop/zips/two-folders.zip");
const zipBuffer = zip.toBuffer();
@ -67,7 +67,7 @@ describe("unzipDrop using real zip files", () => {
it("should correctly extract a zip with a single root with a file", async () => {
const appName = "nested";
const outputPath = path.join(APPLICATIONS_PATH, appName);
const outputPath = path.join(APPLICATIONS_PATH, appName, "code");
const zip = new AdmZip("./__test__/drop/zips/nested.zip");
const zipBuffer = zip.toBuffer();
@ -83,7 +83,7 @@ describe("unzipDrop using real zip files", () => {
it("should correctly extract a zip with a single root with a folder", async () => {
const appName = "folder-with-sibling-file";
const outputPath = path.join(APPLICATIONS_PATH, appName);
const outputPath = path.join(APPLICATIONS_PATH, appName, "code");
const zip = new AdmZip("./__test__/drop/zips/folder-with-sibling-file.zip");
const zipBuffer = zip.toBuffer();

View File

@ -63,6 +63,7 @@ const mySchema = z.discriminatedUnion("type", [
z
.object({
type: z.literal("file"),
filePath: z.string().min(1, "File path required"),
content: z.string().optional(),
})
.merge(mountSchema),
@ -81,7 +82,7 @@ export const AddVolumes = ({
defaultValues: {
type: serviceType === "compose" ? "file" : "bind",
hostPath: "",
mountPath: "",
mountPath: serviceType === "compose" ? "/" : "",
},
resolver: zodResolver(mySchema),
});
@ -125,6 +126,7 @@ export const AddVolumes = ({
serviceId,
content: data.content,
mountPath: data.mountPath,
filePath: data.filePath,
type: data.type,
serviceType,
})
@ -288,6 +290,7 @@ export const AddVolumes = ({
)}
{type === "file" && (
<>
<FormField
control={form.control}
name="content"
@ -307,14 +310,33 @@ export const AddVolumes = ({
</FormItem>
)}
/>
<FormField
control={form.control}
name="filePath"
render={({ field }) => (
<FormItem>
<FormLabel>File Path</FormLabel>
<FormControl>
<FormControl>
<Input
placeholder="Name of the file"
{...field}
/>
</FormControl>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{serviceType !== "compose" && (
<FormField
control={form.control}
name="mountPath"
render={({ field }) => (
<FormItem>
<FormLabel>Mount Path</FormLabel>
<FormLabel>Mount Path (In the container)</FormLabel>
<FormControl>
<Input placeholder="Mount Path" {...field} />
</FormControl>
@ -323,6 +345,7 @@ export const AddVolumes = ({
</FormItem>
)}
/>
)}
</div>
</div>
</form>

View File

@ -73,8 +73,7 @@ export const ShowVolumes = ({ applicationId }: Props) => {
key={mount.mountId}
className="flex w-full flex-col sm:flex-row sm:items-center justify-between gap-4 sm:gap-10 border rounded-lg p-4"
>
{/* <Package className="size-8 self-center text-muted-foreground" /> */}
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 flex-col gap-4 sm:gap-8">
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 flex-col gap-4 sm:gap-8">
<div className="flex flex-col gap-1">
<span className="font-medium">Mount Type</span>
<span className="text-sm text-muted-foreground">
@ -91,12 +90,21 @@ export const ShowVolumes = ({ applicationId }: Props) => {
)}
{mount.type === "file" && (
<>
<div className="flex flex-col gap-1">
<span className="font-medium">Content</span>
<span className="text-sm text-muted-foreground">
{mount.content}
</span>
</div>
<div className="flex flex-col gap-1">
<span className="font-medium">File Path</span>
<span className="text-sm text-muted-foreground">
{mount.filePath}
</span>
</div>
</>
)}
{mount.type === "bind" && (
<div className="flex flex-col gap-1">
@ -118,6 +126,7 @@ export const ShowVolumes = ({ applicationId }: Props) => {
mountId={mount.mountId}
type={mount.type}
refetch={refetch}
serviceType="application"
/>
<DeleteVolume mountId={mount.mountId} refetch={refetch} />
</div>

View File

@ -48,6 +48,7 @@ const mySchema = z.discriminatedUnion("type", [
.object({
type: z.literal("file"),
content: z.string().optional(),
filePath: z.string().min(1, "File path required"),
})
.merge(mountSchema),
]);
@ -58,9 +59,23 @@ interface Props {
mountId: string;
type: "bind" | "volume" | "file";
refetch: () => void;
serviceType:
| "application"
| "postgres"
| "redis"
| "mongo"
| "redis"
| "mysql"
| "mariadb"
| "compose";
}
export const UpdateVolume = ({ mountId, type, refetch }: Props) => {
export const UpdateVolume = ({
mountId,
type,
refetch,
serviceType,
}: Props) => {
const utils = api.useUtils();
const { data } = api.mounts.one.useQuery(
{
@ -103,6 +118,7 @@ export const UpdateVolume = ({ mountId, type, refetch }: Props) => {
form.reset({
content: data.content || "",
mountPath: data.mountPath,
filePath: data.filePath || "",
type: "file",
});
}
@ -141,6 +157,7 @@ export const UpdateVolume = ({ mountId, type, refetch }: Props) => {
content: data.content,
mountPath: data.mountPath,
type: data.type,
filePath: data.filePath,
mountId,
})
.then(() => {
@ -166,6 +183,11 @@ export const UpdateVolume = ({ mountId, type, refetch }: Props) => {
<DialogDescription>Update the mount</DialogDescription>
</DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
{type === "file" && (
<AlertBlock type="warning">
Updating the mount will recreate the file or directory.
</AlertBlock>
)}
<Form {...form}>
<form
@ -211,6 +233,7 @@ export const UpdateVolume = ({ mountId, type, refetch }: Props) => {
)}
{type === "file" && (
<>
<FormField
control={form.control}
name="content"
@ -230,13 +253,33 @@ export const UpdateVolume = ({ mountId, type, refetch }: Props) => {
</FormItem>
)}
/>
<FormField
control={form.control}
name="filePath"
render={({ field }) => (
<FormItem>
<FormLabel>File Path</FormLabel>
<FormControl>
<Input
disabled
placeholder="Name of the file"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{serviceType !== "compose" && (
<FormField
control={form.control}
name="mountPath"
render={({ field }) => (
<FormItem>
<FormLabel>Mount Path</FormLabel>
<FormLabel>Mount Path (In the container)</FormLabel>
<FormControl>
<Input placeholder="Mount Path" {...field} />
</FormControl>
@ -245,6 +288,7 @@ export const UpdateVolume = ({ mountId, type, refetch }: Props) => {
</FormItem>
)}
/>
)}
</div>
<DialogFooter>
<Button

View File

@ -74,7 +74,7 @@ export const ShowVolumesCompose = ({ composeId }: Props) => {
key={mount.mountId}
className="flex w-full flex-col sm:flex-row sm:items-center justify-between gap-4 sm:gap-10 border rounded-lg p-4"
>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 flex-col gap-4 sm:gap-8">
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 flex-col gap-4 sm:gap-8">
<div className="flex flex-col gap-1">
<span className="font-medium">Mount Type</span>
<span className="text-sm text-muted-foreground">
@ -91,12 +91,20 @@ export const ShowVolumesCompose = ({ composeId }: Props) => {
)}
{mount.type === "file" && (
<>
<div className="flex flex-col gap-1">
<span className="font-medium">Content</span>
<span className="text-sm text-muted-foreground w-40 truncate">
{mount.content}
</span>
</div>
<div className="flex flex-col gap-1">
<span className="font-medium">File Path</span>
<span className="text-sm text-muted-foreground">
{mount.filePath}
</span>
</div>
</>
)}
{mount.type === "bind" && (
<div className="flex flex-col gap-1">
@ -118,6 +126,7 @@ export const ShowVolumesCompose = ({ composeId }: Props) => {
mountId={mount.mountId}
type={mount.type}
refetch={refetch}
serviceType="compose"
/>
<DeleteVolume mountId={mount.mountId} refetch={refetch} />
</div>

View File

@ -117,6 +117,7 @@ export const ShowVolumes = ({ mariadbId }: Props) => {
mountId={mount.mountId}
type={mount.type}
refetch={refetch}
serviceType="mariadb"
/>
<DeleteVolume mountId={mount.mountId} refetch={refetch} />
</div>

View File

@ -113,6 +113,7 @@ export const ShowVolumes = ({ mongoId }: Props) => {
mountId={mount.mountId}
type={mount.type}
refetch={refetch}
serviceType="mongo"
/>
<DeleteVolume mountId={mount.mountId} refetch={refetch} />
</div>

View File

@ -86,12 +86,14 @@ export const ShowVolumes = ({ mysqlId }: Props) => {
)}
{mount.type === "file" && (
<>
<div className="flex flex-col gap-1">
<span className="font-medium">Content</span>
<span className="text-sm text-muted-foreground">
{mount.content}
</span>
</div>
</>
)}
{mount.type === "bind" && (
<div className="flex flex-col gap-1">
@ -113,6 +115,7 @@ export const ShowVolumes = ({ mysqlId }: Props) => {
mountId={mount.mountId}
type={mount.type}
refetch={refetch}
serviceType="mysql"
/>
<DeleteVolume mountId={mount.mountId} refetch={refetch} />
</div>

View File

@ -118,6 +118,7 @@ export const ShowVolumes = ({ postgresId }: Props) => {
mountId={mount.mountId}
type={mount.type}
refetch={refetch}
serviceType="postgres"
/>
<DeleteVolume mountId={mount.mountId} refetch={refetch} />
</div>

View File

@ -115,6 +115,7 @@ export const ShowVolumes = ({ redisId }: Props) => {
mountId={mount.mountId}
type={mount.type}
refetch={refetch}
serviceType="redis"
/>
<DeleteVolume mountId={mount.mountId} refetch={refetch} />
</div>

View File

@ -0,0 +1 @@
ALTER TABLE "mount" ADD COLUMN "filePath" text;

File diff suppressed because it is too large Load Diff

View File

@ -169,6 +169,13 @@
"when": 1721542782659,
"tag": "0023_icy_maverick",
"breakpoints": true
},
{
"idx": 24,
"version": "6",
"when": 1721603595092,
"tag": "0024_dapper_supernaut",
"breakpoints": true
}
]
}

View File

@ -156,7 +156,7 @@ export const deployCompose = async ({
await cloneGithubRepository(admin, compose, deployment.logPath, true);
} else if (compose.sourceType === "git") {
await cloneGitRepository(compose, deployment.logPath, true);
} else {
} else if (compose.sourceType === "raw") {
await createComposeFile(compose, deployment.logPath);
}
await buildCompose(compose, deployment.logPath);

View File

@ -1,12 +1,14 @@
import { unlink } from "node:fs/promises";
import path from "node:path";
import { APPLICATIONS_PATH } from "@/server/constants";
import { rmdir, stat, unlink } from "node:fs/promises";
import path, { join } from "node:path";
import { APPLICATIONS_PATH, COMPOSE_PATH } from "@/server/constants";
import { db } from "@/server/db";
import {
type ServiceType,
type apiCreateMount,
mounts,
} from "@/server/db/schema";
import { createFile } from "@/server/utils/docker/utils";
import { removeFileOrDirectory } from "@/server/utils/filesystem/directory";
import { TRPCError } from "@trpc/server";
import { type SQL, eq, sql } from "drizzle-orm";
@ -50,6 +52,10 @@ export const createMount = async (input: typeof apiCreateMount._type) => {
message: "Error input: Inserting mount",
});
}
if (value.type === "file") {
await createFileMount(value.mountId);
}
return value;
} catch (error) {
console.log(error);
@ -61,6 +67,21 @@ export const createMount = async (input: typeof apiCreateMount._type) => {
}
};
export const createFileMount = async (mountId: string) => {
try {
const mount = await findMountById(mountId);
const baseFilePath = await getBaseFilesPath(mountId);
await createFile(baseFilePath, mount.filePath || "", mount.content || "");
} catch (error) {
console.log(`Error to create the file mount: ${error}`);
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error to create the mount",
cause: error,
});
}
};
export const findMountById = async (mountId: string) => {
const mount = await db.query.mounts.findFirst({
where: eq(mounts.mountId, mountId),
@ -71,6 +92,7 @@ export const findMountById = async (mountId: string) => {
mongo: true,
mysql: true,
redis: true,
compose: true,
},
});
if (!mount) {
@ -86,15 +108,29 @@ export const updateMount = async (
mountId: string,
mountData: Partial<Mount>,
) => {
return await db.transaction(async (transaction) => {
const mount = await db
.update(mounts)
.set({
...mountData,
})
.where(eq(mounts.mountId, mountId))
.returning();
.returning()
.then((value) => value[0]);
if (!mount) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Mount not found",
});
}
if (mount.type === "file") {
await deleteFileMount(mountId);
await createFileMount(mountId);
}
return mount;
});
};
export const findMountsByApplicationId = async (
@ -133,41 +169,10 @@ export const findMountsByApplicationId = async (
};
export const deleteMount = async (mountId: string) => {
const {
type,
mountPath,
serviceType,
application,
mariadb,
mongo,
mysql,
postgres,
redis,
} = await findMountById(mountId);
const { type } = await findMountById(mountId);
let appName = null;
if (serviceType === "application") {
appName = application?.appName;
} else if (serviceType === "postgres") {
appName = postgres?.appName;
} else if (serviceType === "mariadb") {
appName = mariadb?.appName;
} else if (serviceType === "mongo") {
appName = mongo?.appName;
} else if (serviceType === "mysql") {
appName = mysql?.appName;
} else if (serviceType === "redis") {
appName = redis?.appName;
}
if (type === "file" && appName) {
const fileName = mountPath.split("/").pop() || "";
const absoluteBasePath = path.resolve(APPLICATIONS_PATH);
const filePath = path.join(absoluteBasePath, appName, "files", fileName);
try {
await unlink(filePath);
} catch (error) {}
if (type === "file") {
await deleteFileMount(mountId);
}
const deletedMount = await db
@ -176,3 +181,41 @@ export const deleteMount = async (mountId: string) => {
.returning();
return deletedMount[0];
};
export const deleteFileMount = async (mountId: string) => {
const mount = await findMountById(mountId);
if (!mount.filePath) return;
const basePath = await getBaseFilesPath(mountId);
const fullPath = path.join(basePath, mount.filePath);
try {
await removeFileOrDirectory(fullPath);
} catch (error) {}
};
export const getBaseFilesPath = async (mountId: string) => {
const mount = await findMountById(mountId);
let absoluteBasePath = path.resolve(APPLICATIONS_PATH);
let appName = "";
let directoryPath = "";
if (mount.serviceType === "application" && mount.application) {
appName = mount.application.appName;
} else if (mount.serviceType === "postgres" && mount.postgres) {
appName = mount.postgres.appName;
} else if (mount.serviceType === "mariadb" && mount.mariadb) {
appName = mount.mariadb.appName;
} else if (mount.serviceType === "mongo" && mount.mongo) {
appName = mount.mongo.appName;
} else if (mount.serviceType === "mysql" && mount.mysql) {
appName = mount.mysql.appName;
} else if (mount.serviceType === "redis" && mount.redis) {
appName = mount.redis.appName;
} else if (mount.serviceType === "compose" && mount.compose) {
appName = mount.compose.appName;
absoluteBasePath = path.resolve(COMPOSE_PATH);
}
directoryPath = path.join(absoluteBasePath, appName, "files");
return directoryPath;
};

View File

@ -31,6 +31,7 @@ export const mounts = pgTable("mount", {
type: mountType("type").notNull(),
hostPath: text("hostPath"),
volumeName: text("volumeName"),
filePath: text("filePath"),
content: text("content"),
serviceType: serviceType("serviceType").notNull().default("application"),
mountPath: text("mountPath").notNull(),
@ -97,6 +98,7 @@ const createSchema = createInsertSchema(mounts, {
content: z.string().optional(),
mountPath: z.string().min(1),
mountId: z.string().optional(),
filePath: z.string().optional(),
serviceType: z
.enum([
"application",
@ -122,6 +124,7 @@ export const apiCreateMount = createSchema
content: true,
mountPath: true,
serviceType: true,
filePath: true,
})
.extend({
serviceId: z.string().min(1),

View File

@ -8,10 +8,7 @@ import { dirname, join } from "node:path";
import { COMPOSE_PATH } from "@/server/constants";
import type { InferResultType } from "@/server/types/with";
import boxen from "boxen";
import {
generateFileMountsCompose,
prepareEnvironmentVariables,
} from "../docker/utils";
import { prepareEnvironmentVariables } from "../docker/utils";
import { spawnAsync } from "../process/spawnAsync";
export type ComposeNested = InferResultType<
@ -24,7 +21,6 @@ export const buildCompose = async (compose: ComposeNested, logPath: string) => {
compose;
try {
const command = createCommand(compose);
generateFileMountsCompose(appName, mounts);
createEnvFile(compose);
@ -46,7 +42,7 @@ Compose Type: ${composeType} ✅`;
});
writeStream.write(`\n${logBox}\n`);
const projectPath = join(COMPOSE_PATH, compose.appName);
const projectPath = join(COMPOSE_PATH, compose.appName, "code");
await spawnAsync(
"docker",
[...command.split(" ")],
@ -104,8 +100,8 @@ export const createCommand = (compose: ComposeNested) => {
const createEnvFile = (compose: ComposeNested) => {
const { env, composePath, appName } = compose;
const composeFilePath =
join(COMPOSE_PATH, appName, composePath) ||
join(COMPOSE_PATH, appName, "docker-compose.yml");
join(COMPOSE_PATH, appName, "code", composePath) ||
join(COMPOSE_PATH, appName, "code", "docker-compose.yml");
const envFilePath = join(dirname(composeFilePath), ".env");
let envContent = env || "";

View File

@ -6,8 +6,7 @@ import { recreateDirectory } from "../filesystem/directory";
export const unzipDrop = async (zipFile: File, appName: string) => {
try {
const basePath = APPLICATIONS_PATH;
const outputPath = join(basePath, appName);
const outputPath = join(APPLICATIONS_PATH, appName, "code");
await recreateDirectory(outputPath);
const arrayBuffer = await zipFile.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);

View File

@ -11,6 +11,7 @@ export const buildNixpacks = async (
) => {
const { env, appName } = application;
const buildAppDirectory = getBuildAppDirectory(application);
const envVariables = prepareEnvironmentVariables(env);
try {
const args = ["build", buildAppDirectory, "--name", appName];

View File

@ -1,7 +1,7 @@
import fs from "node:fs";
import path from "node:path";
import type { Readable } from "node:stream";
import { APPLICATIONS_PATH, COMPOSE_PATH, docker } from "@/server/constants";
import { APPLICATIONS_PATH, docker } from "@/server/constants";
import type { ContainerInfo, ResourceRequirements } from "dockerode";
import { parse } from "dotenv";
import type { ApplicationNested } from "../builders";
@ -305,54 +305,36 @@ export const generateFileMounts = (
return mounts
.filter((mount) => mount.type === "file")
.map((mount) => {
const fileName = mount.mountPath.split("/").pop();
if (!fileName) {
throw new Error("File name not found");
}
const fileName = mount.filePath;
const absoluteBasePath = path.resolve(APPLICATIONS_PATH);
const directory = path.join(absoluteBasePath, appName, "files");
const filePath = path.join(directory, fileName);
if (!fs.existsSync(directory)) {
fs.mkdirSync(directory, { recursive: true });
}
fs.writeFileSync(filePath, mount.content || "");
const sourcePath = path.join(directory, fileName || "");
return {
Type: "bind" as const,
Source: filePath,
Source: sourcePath,
Target: mount.mountPath,
};
});
};
export const generateFileMountsCompose = (
appName: string,
mounts: ApplicationNested["mounts"],
export const createFile = async (
outputPath: string,
filePath: string,
content: string,
) => {
if (!mounts || mounts.length === 0) {
return [];
try {
const fullPath = path.join(outputPath, filePath);
if (fullPath.endsWith(path.sep) || filePath.endsWith("/")) {
fs.mkdirSync(fullPath, { recursive: true });
return;
}
return mounts
.filter((mount) => mount.type === "file")
.map((mount) => {
const fileName = path.basename(mount.mountPath);
const directory = path.join(
COMPOSE_PATH,
appName,
path.dirname(mount.mountPath),
);
const directory = path.dirname(fullPath);
fs.mkdirSync(directory, { recursive: true });
const filePath = path.join(directory, fileName);
fs.writeFileSync(filePath, mount.content || "");
return {};
});
fs.writeFileSync(fullPath, content || "");
} catch (error) {
throw error;
}
};
export const getServiceContainer = async (appName: string) => {

View File

@ -25,6 +25,15 @@ export const removeDirectoryIfExistsContent = async (
}
};
export const removeFileOrDirectory = async (path: string) => {
try {
await execAsync(`rm -rf ${path}`);
} catch (error) {
console.error(`Error to remove ${path}: ${error}`);
throw error;
}
};
export const removeDirectoryCode = async (appName: string) => {
const directoryPath = path.join(APPLICATIONS_PATH, appName);
@ -72,9 +81,11 @@ export const getBuildAppDirectory = (application: Application) => {
return path.join(
APPLICATIONS_PATH,
appName,
"code",
buildPath ?? "",
dockerfile || "",
);
}
return path.join(APPLICATIONS_PATH, appName, buildPath ?? "");
return path.join(APPLICATIONS_PATH, appName, "code", buildPath ?? "");
};

View File

@ -28,7 +28,7 @@ export const cloneGitRepository = async (
const writeStream = createWriteStream(logPath, { flags: "a" });
const keyPath = path.join(SSH_PATH, `${appName}_rsa`);
const basePath = isCompose ? COMPOSE_PATH : APPLICATIONS_PATH;
const outputPath = join(basePath, appName);
const outputPath = join(basePath, appName, "code");
const knownHostsPath = path.join(SSH_PATH, "known_hosts");
try {

View File

@ -93,7 +93,7 @@ export const cloneGithubRepository = async (
});
}
const basePath = isCompose ? COMPOSE_PATH : APPLICATIONS_PATH;
const outputPath = join(basePath, appName);
const outputPath = join(basePath, appName, "code");
const octokit = authGithub(admin);
const token = await getGithubToken(octokit);
const repoclone = `github.com/${owner}/${repository}.git`;

View File

@ -8,7 +8,7 @@ import { recreateDirectory } from "../filesystem/directory";
export const createComposeFile = async (compose: Compose, logPath: string) => {
const { appName, composeFile } = compose;
const writeStream = createWriteStream(logPath, { flags: "a" });
const outputPath = join(COMPOSE_PATH, appName);
const outputPath = join(COMPOSE_PATH, appName, "code");
try {
await recreateDirectory(outputPath);