/** * Volumes subcommands for CLI — add/list/remove bind mounts on docker-compose apps. * * @module */ import { getCliSdk, chalk, Table } from "../actions.js"; interface IComposeService { image?: string; volumes?: string[]; [key: string]: unknown; } interface IComposeFile { services?: Record; [key: string]: unknown; } /** * Parse a volume string like "/host/path:/container/path" or "named_vol:/container/path". */ function parseVolumeEntry(v: string): { source: string; target: string } { const colonIdx = v.indexOf(":"); if (colonIdx === -1) return { source: "", target: v }; return { source: v.slice(0, colonIdx), target: v.slice(colonIdx + 1) }; } /** * List volumes for an application. */ export async function volumesListCommand( uuid: string, options: { service?: string }, ): Promise { try { const sdk = getCliSdk(); const app = await sdk.applications.get(uuid); if (!app.docker_compose_raw) { console.log(chalk.yellow("Application has no docker_compose_raw (not a docker-compose app).")); return; } let compose: IComposeFile; try { compose = Bun.YAML.parse(app.docker_compose_raw) as IComposeFile; } catch { console.error(chalk.red("Failed to parse docker_compose_raw YAML.")); return; } const services = compose.services ?? {}; const serviceNames = Object.keys(services); if (serviceNames.length === 0) { console.log(chalk.yellow("No services found in docker-compose.")); return; } // Filter by service if specified const displayServices = options.service ? serviceNames.filter((s) => s === options.service) : serviceNames; if (options.service && displayServices.length === 0) { console.error( chalk.red(`Service "${options.service}" not found. Available: ${serviceNames.join(", ")}`), ); return; } for (const svcName of displayServices) { const svc = services[svcName]; const volumes = svc?.volumes ?? []; console.log(chalk.cyan(`\nService: ${chalk.bold(svcName)}`)); if (volumes.length === 0) { console.log(chalk.gray(" No volumes configured.")); continue; } const table = new Table({ head: [chalk.cyan("Source"), chalk.cyan("Target"), chalk.cyan("Type")], }); for (const vol of volumes) { const { source, target } = parseVolumeEntry(vol); const volType = source.startsWith("/") ? "bind" : "named"; table.push([source || "(no source)", target, volType]); } console.log(table.toString()); } } catch (error) { console.error( chalk.red(`Error: ${error instanceof Error ? error.message : String(error)}`), ); } } /** * Add a volume (bind mount) to an application. */ export async function volumesAddCommand( uuid: string, options: { source: string; target: string; service?: string; noRestart?: boolean; }, ): Promise { try { const sdk = getCliSdk(); const app = await sdk.applications.get(uuid); if (!app.docker_compose_raw) { console.log( chalk.yellow("Application has no docker_compose_raw. Only docker-compose buildPack apps support volume management via CLI."), ); return; } let compose: IComposeFile; try { compose = Bun.YAML.parse(app.docker_compose_raw) as IComposeFile; } catch { console.error(chalk.red("Failed to parse docker_compose_raw YAML.")); return; } const services = compose.services ?? {}; const serviceNames = Object.keys(services); // Determine target service let targetService = options.service; if (!targetService) { if (serviceNames.length === 1) { targetService = serviceNames[0]; } else { console.error( chalk.red( `Multiple services found (${serviceNames.join(", ")}). Use --service to specify which one.`, ), ); return; } } if (!services[targetService]) { console.error( chalk.red(`Service "${targetService}" not found. Available: ${serviceNames.join(", ")}`), ); return; } const svc = services[targetService]; const volumes = svc.volumes ?? []; // Check if volume already exists const existing = volumes.find((v) => { const { target: t } = parseVolumeEntry(v); return t === options.target; }); if (existing) { console.log(chalk.yellow(`Volume with target "${options.target}" already exists — skipping.`)); return; } // Add the volume const volStr = `${options.source}:${options.target}`; svc.volumes = [...volumes, volStr]; // Serialize back to YAML const updatedYaml = Bun.YAML.stringify(compose); // PATCH the application console.log(chalk.cyan(`Adding volume: ${chalk.bold(volStr)} to service "${targetService}"...`)); await sdk.applications.update(uuid, { dockerComposeRaw: updatedYaml }); console.log(chalk.green("Volume added successfully.")); // Deploy if not --no-restart if (!options.noRestart) { console.log(chalk.cyan("Redeploying to apply changes...")); await sdk.applications.deploy(uuid); console.log(chalk.green("Redeploy triggered.")); } else { console.log(chalk.gray("Skipped redeploy (--no-restart). Changes apply on next deploy.")); } } catch (error) { console.error( chalk.red(`Error: ${error instanceof Error ? error.message : String(error)}`), ); } } /** * Remove a volume from an application. */ export async function volumesRemoveCommand( uuid: string, options: { target: string; service?: string; noRestart?: boolean; }, ): Promise { try { const sdk = getCliSdk(); const app = await sdk.applications.get(uuid); if (!app.docker_compose_raw) { console.log( chalk.yellow("Application has no docker_compose_raw. Only docker-compose buildPack apps support volume management via CLI."), ); return; } let compose: IComposeFile; try { compose = Bun.YAML.parse(app.docker_compose_raw) as IComposeFile; } catch { console.error(chalk.red("Failed to parse docker_compose_raw YAML.")); return; } const services = compose.services ?? {}; const serviceNames = Object.keys(services); // Determine target service let targetService = options.service; if (!targetService) { if (serviceNames.length === 1) { targetService = serviceNames[0]; } else { console.error( chalk.red( `Multiple services found (${serviceNames.join(", ")}). Use --service to specify which one.`, ), ); return; } } if (!services[targetService]) { console.error( chalk.red(`Service "${targetService}" not found. Available: ${serviceNames.join(", ")}`), ); return; } const svc = services[targetService]; const volumes = svc.volumes ?? []; // Find volume by target path const idx = volumes.findIndex((v) => { const { target: t } = parseVolumeEntry(v); return t === options.target; }); if (idx === -1) { console.error(chalk.red(`No volume found with target "${options.target}".`)); return; } const removed = volumes[idx]; svc.volumes = volumes.filter((_, i) => i !== idx); // Serialize back to YAML const updatedYaml = Bun.YAML.stringify(compose); // PATCH the application console.log(chalk.cyan(`Removing volume: ${chalk.bold(removed)} from service "${targetService}"...`)); await sdk.applications.update(uuid, { dockerComposeRaw: updatedYaml }); console.log(chalk.green("Volume removed successfully.")); // Deploy if not --no-restart if (!options.noRestart) { console.log(chalk.cyan("Redeploying to apply changes...")); await sdk.applications.deploy(uuid); console.log(chalk.green("Redeploy triggered.")); } else { console.log(chalk.gray("Skipped redeploy (--no-restart). Changes apply on next deploy.")); } } catch (error) { console.error( chalk.red(`Error: ${error instanceof Error ? error.message : String(error)}`), ); } }