Merge pull request #543 from coollabsio/next

v3.5.0
This commit is contained in:
Andras Bacsai 2022-08-17 11:16:28 +02:00 committed by GitHub
commit d93506a18c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 527 additions and 376 deletions

View File

@ -0,0 +1,20 @@
-- RedefineTables
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_ApplicationSettings" (
"id" TEXT NOT NULL PRIMARY KEY,
"applicationId" TEXT NOT NULL,
"dualCerts" BOOLEAN NOT NULL DEFAULT false,
"debug" BOOLEAN NOT NULL DEFAULT false,
"previews" BOOLEAN NOT NULL DEFAULT false,
"autodeploy" BOOLEAN NOT NULL DEFAULT true,
"isBot" BOOLEAN NOT NULL DEFAULT false,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "ApplicationSettings_applicationId_fkey" FOREIGN KEY ("applicationId") REFERENCES "Application" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
INSERT INTO "new_ApplicationSettings" ("applicationId", "autodeploy", "createdAt", "debug", "dualCerts", "id", "previews", "updatedAt") SELECT "applicationId", "autodeploy", "createdAt", "debug", "dualCerts", "id", "previews", "updatedAt" FROM "ApplicationSettings";
DROP TABLE "ApplicationSettings";
ALTER TABLE "new_ApplicationSettings" RENAME TO "ApplicationSettings";
CREATE UNIQUE INDEX "ApplicationSettings_applicationId_key" ON "ApplicationSettings"("applicationId");
PRAGMA foreign_key_check;
PRAGMA foreign_keys=ON;

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Setting" ADD COLUMN "DNSServers" TEXT;

View File

@ -20,6 +20,7 @@ model Setting {
proxyHash String? proxyHash String?
isAutoUpdateEnabled Boolean @default(false) isAutoUpdateEnabled Boolean @default(false)
isDNSCheckEnabled Boolean @default(true) isDNSCheckEnabled Boolean @default(true)
DNSServers String?
isTraefikUsed Boolean @default(true) isTraefikUsed Boolean @default(true)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@ -124,6 +125,7 @@ model ApplicationSettings {
debug Boolean @default(false) debug Boolean @default(false)
previews Boolean @default(false) previews Boolean @default(false)
autodeploy Boolean @default(true) autodeploy Boolean @default(true)
isBot Boolean @default(false)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
application Application @relation(fields: [applicationId], references: [id]) application Application @relation(fields: [applicationId], references: [id])

View File

@ -5,8 +5,10 @@ import env from '@fastify/env';
import cookie from '@fastify/cookie'; import cookie from '@fastify/cookie';
import path, { join } from 'path'; import path, { join } from 'path';
import autoLoad from '@fastify/autoload'; import autoLoad from '@fastify/autoload';
import { asyncExecShell, isDev, listSettings, prisma } from './lib/common'; import { asyncExecShell, isDev, listSettings, prisma, version } from './lib/common';
import { scheduler } from './lib/scheduler'; import { scheduler } from './lib/scheduler';
import axios from 'axios';
import compareVersions from 'compare-versions';
declare module 'fastify' { declare module 'fastify' {
interface FastifyInstance { interface FastifyInstance {
@ -113,10 +115,24 @@ fastify.listen({ port, host }, async (err: any, address: any) => {
setInterval(async () => { setInterval(async () => {
const { isAutoUpdateEnabled } = await prisma.setting.findFirst(); const { isAutoUpdateEnabled } = await prisma.setting.findFirst();
if (isAutoUpdateEnabled) { if (isAutoUpdateEnabled) {
const currentVersion = version;
const { data: versions } = await axios
.get(
`https://get.coollabs.io/versions.json`
, {
params: {
appId: process.env['COOLIFY_APP_ID'] || undefined,
version: currentVersion
}
})
const latestVersion = versions['coolify'].main.version;
const isUpdateAvailable = compareVersions(latestVersion, currentVersion);
if (isUpdateAvailable === 1) {
if (scheduler.workers.has('deployApplication')) { if (scheduler.workers.has('deployApplication')) {
scheduler.workers.get('deployApplication').postMessage("status:autoUpdater"); scheduler.workers.get('deployApplication').postMessage("status:autoUpdater");
} }
} }
}
}, isDev ? 5000 : 60000 * 15) }, isDev ? 5000 : 60000 * 15)
// Cleanup storage // Cleanup storage

View File

@ -298,7 +298,6 @@ import * as buildpacks from '../lib/buildPacks';
} }
}; };
}); });
console.log({port})
const composeFile = { const composeFile = {
version: '3.8', version: '3.8',
services: { services: {

View File

@ -17,7 +17,7 @@ import { checkContainer, removeContainer } from './docker';
import { day } from './dayjs'; import { day } from './dayjs';
import * as serviceFields from './serviceFields' import * as serviceFields from './serviceFields'
export const version = '3.4.0'; export const version = '3.5.0';
export const isDev = process.env.NODE_ENV === 'development'; export const isDev = process.env.NODE_ENV === 'development';
const algorithm = 'aes-256-ctr'; const algorithm = 'aes-256-ctr';
@ -307,6 +307,10 @@ export async function checkDoubleBranch(branch: string, projectId: number): Prom
} }
export async function isDNSValid(hostname: any, domain: string): Promise<any> { export async function isDNSValid(hostname: any, domain: string): Promise<any> {
const { isIP } = await import('is-ip'); const { isIP } = await import('is-ip');
const { DNSServers } = await listSettings();
if (DNSServers) {
dns.setServers([DNSServers]);
}
let resolves = []; let resolves = [];
try { try {
if (isIP(hostname)) { if (isIP(hostname)) {
@ -320,7 +324,6 @@ export async function isDNSValid(hostname: any, domain: string): Promise<any> {
try { try {
let ipDomainFound = false; let ipDomainFound = false;
dns.setServers(['1.1.1.1', '8.8.8.8']);
const dnsResolve = await dns.resolve4(domain); const dnsResolve = await dns.resolve4(domain);
if (dnsResolve.length > 0) { if (dnsResolve.length > 0) {
for (const ip of dnsResolve) { for (const ip of dnsResolve) {
@ -412,7 +415,12 @@ export async function checkDomainsIsValidInDNS({ hostname, fqdn, dualCerts }): P
const { isIP } = await import('is-ip'); const { isIP } = await import('is-ip');
const domain = getDomain(fqdn); const domain = getDomain(fqdn);
const domainDualCert = domain.includes('www.') ? domain.replace('www.', '') : `www.${domain}`; const domainDualCert = domain.includes('www.') ? domain.replace('www.', '') : `www.${domain}`;
dns.setServers(['1.1.1.1', '8.8.8.8']);
const { DNSServers } = await listSettings();
if (DNSServers) {
dns.setServers([DNSServers]);
}
let resolves = []; let resolves = [];
try { try {
if (isIP(hostname)) { if (isIP(hostname)) {

View File

@ -20,7 +20,6 @@ const options: any = {
} }
if (message.caller === 'cleanupStorage') { if (message.caller === 'cleanupStorage') {
if (!scheduler.workers.has('cleanupStorage')) { if (!scheduler.workers.has('cleanupStorage')) {
await scheduler.stop('deployApplication');
await scheduler.run('cleanupStorage') await scheduler.run('cleanupStorage')
} }
} }

View File

@ -5,7 +5,7 @@ import axios from 'axios';
import { FastifyReply } from 'fastify'; import { FastifyReply } from 'fastify';
import { day } from '../../../../lib/dayjs'; import { day } from '../../../../lib/dayjs';
import { setDefaultBaseImage, setDefaultConfiguration } from '../../../../lib/buildPacks/common'; import { setDefaultBaseImage, setDefaultConfiguration } from '../../../../lib/buildPacks/common';
import { checkDomainsIsValidInDNS, checkDoubleBranch, decrypt, encrypt, errorHandler, executeDockerCmd, generateSshKeyPair, getContainerUsage, getDomain, getFreeExposedPort, isDev, isDomainConfigured, prisma, stopBuild, uniqueName } from '../../../../lib/common'; import { checkDomainsIsValidInDNS, checkDoubleBranch, decrypt, encrypt, errorHandler, executeDockerCmd, generateSshKeyPair, getContainerUsage, getDomain, getFreeExposedPort, isDev, isDomainConfigured, listSettings, prisma, stopBuild, uniqueName } from '../../../../lib/common';
import { checkContainer, formatLabelsOnDocker, isContainerExited, removeContainer } from '../../../../lib/docker'; import { checkContainer, formatLabelsOnDocker, isContainerExited, removeContainer } from '../../../../lib/docker';
import { scheduler } from '../../../../lib/scheduler'; import { scheduler } from '../../../../lib/scheduler';
@ -18,7 +18,7 @@ export async function listApplications(request: FastifyRequest) {
const { teamId } = request.user const { teamId } = request.user
const applications = await prisma.application.findMany({ const applications = await prisma.application.findMany({
where: { teams: { some: { id: teamId === '0' ? undefined : teamId } } }, where: { teams: { some: { id: teamId === '0' ? undefined : teamId } } },
include: { teams: true, destinationDocker: true } include: { teams: true, destinationDocker: true, settings: true }
}); });
const settings = await prisma.setting.findFirst() const settings = await prisma.setting.findFirst()
return { return {
@ -90,10 +90,11 @@ export async function getApplication(request: FastifyRequest<OnlyId>) {
const { teamId } = request.user const { teamId } = request.user
const appId = process.env['COOLIFY_APP_ID']; const appId = process.env['COOLIFY_APP_ID'];
const application: any = await getApplicationFromDB(id, teamId); const application: any = await getApplicationFromDB(id, teamId);
const settings = await listSettings();
return { return {
application, application,
appId appId,
settings
}; };
} catch ({ status, message }) { } catch ({ status, message }) {
@ -275,7 +276,7 @@ export async function saveApplication(request: FastifyRequest<SaveApplication>,
export async function saveApplicationSettings(request: FastifyRequest<SaveApplicationSettings>, reply: FastifyReply) { export async function saveApplicationSettings(request: FastifyRequest<SaveApplicationSettings>, reply: FastifyReply) {
try { try {
const { id } = request.params const { id } = request.params
const { debug, previews, dualCerts, autodeploy, branch, projectId } = request.body const { debug, previews, dualCerts, autodeploy, branch, projectId, isBot } = request.body
const isDouble = await checkDoubleBranch(branch, projectId); const isDouble = await checkDoubleBranch(branch, projectId);
if (isDouble && autodeploy) { if (isDouble && autodeploy) {
await prisma.applicationSettings.updateMany({ where: { application: { branch, projectId } }, data: { autodeploy: false } }) await prisma.applicationSettings.updateMany({ where: { application: { branch, projectId } }, data: { autodeploy: false } })
@ -283,7 +284,7 @@ export async function saveApplicationSettings(request: FastifyRequest<SaveApplic
} }
await prisma.application.update({ await prisma.application.update({
where: { id }, where: { id },
data: { settings: { update: { debug, previews, dualCerts, autodeploy } } }, data: { fqdn: isBot ? null : undefined, settings: { update: { debug, previews, dualCerts, autodeploy, isBot } } },
include: { destinationDocker: true } include: { destinationDocker: true }
}); });
return reply.code(201).send(); return reply.code(201).send();

View File

@ -25,7 +25,7 @@ export interface SaveApplication extends OnlyId {
} }
export interface SaveApplicationSettings extends OnlyId { export interface SaveApplicationSettings extends OnlyId {
Querystring: { domain: string; }; Querystring: { domain: string; };
Body: { debug: boolean; previews: boolean; dualCerts: boolean; autodeploy: boolean; branch: string; projectId: number; }; Body: { debug: boolean; previews: boolean; dualCerts: boolean; autodeploy: boolean; branch: string; projectId: number; isBot: boolean; };
} }
export interface DeleteApplication extends OnlyId { export interface DeleteApplication extends OnlyId {
Querystring: { domain: string; }; Querystring: { domain: string; };

View File

@ -4,7 +4,7 @@ import axios from 'axios';
import compare from 'compare-versions'; import compare from 'compare-versions';
import cuid from 'cuid'; import cuid from 'cuid';
import bcrypt from 'bcryptjs'; import bcrypt from 'bcryptjs';
import { asyncExecShell, asyncSleep, cleanupDockerStorage, errorHandler, isDev, prisma, uniqueName, version } from '../../../lib/common'; import { asyncExecShell, asyncSleep, cleanupDockerStorage, errorHandler, isDev, listSettings, prisma, uniqueName, version } from '../../../lib/common';
import type { FastifyReply, FastifyRequest } from 'fastify'; import type { FastifyReply, FastifyRequest } from 'fastify';
import type { Login, Update } from '.'; import type { Login, Update } from '.';
@ -97,7 +97,8 @@ export async function showDashboard(request: FastifyRequest) {
const userId = request.user.userId; const userId = request.user.userId;
const teamId = request.user.teamId; const teamId = request.user.teamId;
const applications = await prisma.application.findMany({ const applications = await prisma.application.findMany({
where: { teams: { some: { id: teamId === '0' ? undefined : teamId } } } where: { teams: { some: { id: teamId === '0' ? undefined : teamId } } },
include: { settings: true }
}); });
const databases = await prisma.database.findMany({ const databases = await prisma.database.findMany({
where: { teams: { some: { id: teamId === '0' ? undefined : teamId } } } where: { teams: { some: { id: teamId === '0' ? undefined : teamId } } }
@ -105,10 +106,12 @@ export async function showDashboard(request: FastifyRequest) {
const services = await prisma.service.findMany({ const services = await prisma.service.findMany({
where: { teams: { some: { id: teamId === '0' ? undefined : teamId } } } where: { teams: { some: { id: teamId === '0' ? undefined : teamId } } }
}); });
const settings = await listSettings();
return { return {
applications, applications,
databases, databases,
services, services,
settings,
}; };
} catch ({ status, message }) { } catch ({ status, message }) {
return errorHandler({ status, message }) return errorHandler({ status, message })

View File

@ -33,12 +33,13 @@ export async function saveSettings(request: FastifyRequest<SaveSettings>, reply:
minPort, minPort,
maxPort, maxPort,
isAutoUpdateEnabled, isAutoUpdateEnabled,
isDNSCheckEnabled isDNSCheckEnabled,
DNSServers
} = request.body } = request.body
const { id } = await listSettings(); const { id } = await listSettings();
await prisma.setting.update({ await prisma.setting.update({
where: { id }, where: { id },
data: { isRegistrationEnabled, dualCerts, isAutoUpdateEnabled, isDNSCheckEnabled } data: { isRegistrationEnabled, dualCerts, isAutoUpdateEnabled, isDNSCheckEnabled, DNSServers }
}); });
if (fqdn) { if (fqdn) {
await prisma.setting.update({ where: { id }, data: { fqdn } }); await prisma.setting.update({ where: { id }, data: { fqdn } });
@ -54,6 +55,10 @@ export async function saveSettings(request: FastifyRequest<SaveSettings>, reply:
export async function deleteDomain(request: FastifyRequest<DeleteDomain>, reply: FastifyReply) { export async function deleteDomain(request: FastifyRequest<DeleteDomain>, reply: FastifyReply) {
try { try {
const { fqdn } = request.body const { fqdn } = request.body
const { DNSServers } = await listSettings();
if (DNSServers) {
dns.setServers([DNSServers]);
}
let ip; let ip;
try { try {
ip = await dns.resolve(fqdn); ip = await dns.resolve(fqdn);

View File

@ -8,7 +8,8 @@ export interface SaveSettings {
minPort: number, minPort: number,
maxPort: number, maxPort: number,
isAutoUpdateEnabled: boolean, isAutoUpdateEnabled: boolean,
isDNSCheckEnabled: boolean isDNSCheckEnabled: boolean,
DNSServers: string
} }
} }
export interface DeleteDomain { export interface DeleteDomain {

View File

@ -83,7 +83,7 @@
disabled={updateStatus.success === false} disabled={updateStatus.success === false}
on:click={update} on:click={update}
class="icons tooltip tooltip-right tooltip-primary bg-gradient-to-r from-purple-500 via-pink-500 to-red-500 text-white duration-75 hover:scale-105" class="icons tooltip tooltip-right tooltip-primary bg-gradient-to-r from-purple-500 via-pink-500 to-red-500 text-white duration-75 hover:scale-105"
data-tip="Update available!" data-tip="Update Available!"
> >
{#if updateStatus.loading} {#if updateStatus.loading}
<svg <svg

View File

@ -4,7 +4,7 @@
"wait_new_version_startup": "Waiting for the new version to start...", "wait_new_version_startup": "Waiting for the new version to start...",
"new_version": "New version reachable. Reloading...", "new_version": "New version reachable. Reloading...",
"switch_to_a_different_team": "Switch to a different team...", "switch_to_a_different_team": "Switch to a different team...",
"update_available": "Update available" "update_available": "Update Available"
}, },
"error": { "error": {
"you_can_find_your_way_back": "You can find your way back", "you_can_find_your_way_back": "You can find your way back",
@ -144,8 +144,8 @@
}, },
"preview": { "preview": {
"need_during_buildtime": "Need during buildtime?", "need_during_buildtime": "Need during buildtime?",
"setup_secret_app_first": "You can add secrets to PR/MR deployments. Please add secrets to the application first. <br>Useful for creating <span class='text-applications font-bold'>staging</span> environments.", "setup_secret_app_first": "You can add secrets to PR/MR deployments. Please add secrets to the application first. <br>Useful for creating <span class='text-green-500 font-bold'>staging</span> environments.",
"values_overwriting_app_secrets": "These values overwrite application secrets in PR/MR deployments. Useful for creating <span class='text-applications font-bold'>staging</span> environments.", "values_overwriting_app_secrets": "These values overwrite application secrets in PR/MR deployments. Useful for creating <span class='text-green-500 font-bold'>staging</span> environments.",
"redeploy": "Redeploy", "redeploy": "Redeploy",
"no_previews_available": "No previews available" "no_previews_available": "No previews available"
}, },
@ -163,9 +163,9 @@
}, },
"deployment_queued": "Deployment queued.", "deployment_queued": "Deployment queued.",
"confirm_to_delete": "Are you sure you would like to delete '{{name}}'?", "confirm_to_delete": "Are you sure you would like to delete '{{name}}'?",
"stop_application": "Stop application", "stop_application": "Stop Application",
"permission_denied_stop_application": "You do not have permission to stop the application.", "permission_denied_stop_application": "You do not have permission to stop the application.",
"rebuild_application": "Rebuild application", "rebuild_application": "Rebuild Application",
"permission_denied_rebuild_application": "You do not have permission to rebuild application.", "permission_denied_rebuild_application": "You do not have permission to rebuild application.",
"build_and_start_application": "Deploy", "build_and_start_application": "Deploy",
"permission_denied_build_and_start_application": "You do not have permission to deploy application.", "permission_denied_build_and_start_application": "You do not have permission to deploy application.",
@ -194,14 +194,14 @@
"application": "Application", "application": "Application",
"url_fqdn": "URL (FQDN)", "url_fqdn": "URL (FQDN)",
"domain_fqdn": "Domain (FQDN)", "domain_fqdn": "Domain (FQDN)",
"https_explainer": "If you specify <span class='text-applications font-bold'>https</span>, the application will be accessible only over https. SSL certificate will be generated for you.<br>If you specify <span class='text-applications font-bold'>www</span>, the application will be redirected (302) from non-www and vice versa.<br><br>To modify the domain, you must first stop the application.<br><br><span class='text-white font-bold'>You must set your DNS to point to the server IP in advance.</span>", "https_explainer": "If you specify <span class='text-green-500 font-bold'>https</span>, the application will be accessible only over https. SSL certificate will be generated for you.<br>If you specify <span class='text-green-500 font-bold'>www</span>, the application will be redirected (302) from non-www and vice versa.<br><br>To modify the domain, you must first stop the application.<br><br><span class='text-white font-bold'>You must set your DNS to point to the server IP in advance.</span>",
"ssl_www_and_non_www": "Generate SSL for www and non-www?", "ssl_www_and_non_www": "Generate SSL for www and non-www?",
"ssl_explainer": "It will generate certificates for both www and non-www. <br>You need to have <span class='font-bold text-applications'>both DNS entries</span> set in advance.<br><br>Useful if you expect to have visitors on both.", "ssl_explainer": "It will generate certificates for both www and non-www. <br>You need to have <span class='font-bold text-green-500'>both DNS entries</span> set in advance.<br><br>Useful if you expect to have visitors on both.",
"install_command": "Install Command", "install_command": "Install Command",
"build_command": "Build Command", "build_command": "Build Command",
"start_command": "Start Command", "start_command": "Start Command",
"directory_to_use_explainer": "Directory to use as the base for all commands.<br>Could be useful with <span class='text-applications font-bold'>monorepos</span>.", "directory_to_use_explainer": "Directory to use as the base for all commands.<br>Could be useful with <span class='text-green-500 font-bold'>monorepos</span>.",
"publish_directory_explainer": "Directory containing all the assets for deployment. <br> For example: <span class='text-applications font-bold'>dist</span>,<span class='text-applications font-bold'>_site</span> or <span class='text-applications font-bold'>public</span>.", "publish_directory_explainer": "Directory containing all the assets for deployment. <br> For example: <span class='text-green-500 font-bold'>dist</span>,<span class='text-green-500 font-bold'>_site</span> or <span class='text-green-500 font-bold'>public</span>.",
"features": "Features", "features": "Features",
"enable_automatic_deployment": "Enable Automatic Deployment", "enable_automatic_deployment": "Enable Automatic Deployment",
"enable_auto_deploy_webhooks": "Enable automatic deployment through webhooks.", "enable_auto_deploy_webhooks": "Enable automatic deployment through webhooks.",

View File

@ -65,7 +65,7 @@
"features": "Caractéristiques", "features": "Caractéristiques",
"git_repository": "Dépôt Git", "git_repository": "Dépôt Git",
"git_source": "Source Git", "git_source": "Source Git",
"https_explainer": "Si vous spécifiez <span class='text-applications font-bold'>https</span>, l'application sera accessible uniquement via https. \nUn certificat SSL sera généré pour vous.<br>Si vous spécifiez <span class='text-applications font-bold'>www</span>, l'application sera redirigée (302) à partir de non-www et vice versa \n.<br><br>Pour modifier le domaine, vous devez d'abord arrêter l'application.<br><br><span class='text-white font-bold'>Vous devez configurer, en avance, votre DNS pour pointer vers l'IP du serveur.</span>", "https_explainer": "Si vous spécifiez <span class='text-green-500 font-bold'>https</span>, l'application sera accessible uniquement via https. \nUn certificat SSL sera généré pour vous.<br>Si vous spécifiez <span class='text-green-500 font-bold'>www</span>, l'application sera redirigée (302) à partir de non-www et vice versa \n.<br><br>Pour modifier le domaine, vous devez d'abord arrêter l'application.<br><br><span class='text-white font-bold'>Vous devez configurer, en avance, votre DNS pour pointer vers l'IP du serveur.</span>",
"install_command": "Commande d'installation", "install_command": "Commande d'installation",
"logs": "Journaux des applications", "logs": "Journaux des applications",
"no_applications_found": "Aucune application trouvée", "no_applications_found": "Aucune application trouvée",
@ -78,11 +78,11 @@
"need_during_buildtime": "Besoin pendant la build ?", "need_during_buildtime": "Besoin pendant la build ?",
"no_previews_available": "Aucun aperçu disponible", "no_previews_available": "Aucun aperçu disponible",
"redeploy": "Redéployer", "redeploy": "Redéployer",
"setup_secret_app_first": "Vous pouvez ajouter des secrets aux déploiements PR/MR. \nVeuillez d'abord ajouter des secrets à l'application. \n<br>Utile pour créer des environnements <span class='text-applications font-bold'>de mise en scène</span>.", "setup_secret_app_first": "Vous pouvez ajouter des secrets aux déploiements PR/MR. \nVeuillez d'abord ajouter des secrets à l'application. \n<br>Utile pour créer des environnements <span class='text-green-500 font-bold'>de mise en scène</span>.",
"values_overwriting_app_secrets": "Ces valeurs remplacent les secrets d'application dans les déploiements PR/MR. \nUtile pour créer des environnements <span class='text-applications font-bold'>de mise en scène</span>." "values_overwriting_app_secrets": "Ces valeurs remplacent les secrets d'application dans les déploiements PR/MR. \nUtile pour créer des environnements <span class='text-green-500 font-bold'>de mise en scène</span>."
}, },
"previews": "Aperçus", "previews": "Aperçus",
"publish_directory_explainer": "Répertoire contenant tous les actifs à déployer. \n<br> Par exemple : <span class='text-applications font-bold'>dist</span>,<span class='text-applications font-bold'>_site</span> ou <span \nclass='text-applications font-bold'>public</span>.", "publish_directory_explainer": "Répertoire contenant tous les actifs à déployer. \n<br> Par exemple : <span class='text-green-500 font-bold'>dist</span>,<span class='text-green-500 font-bold'>_site</span> ou <span \nclass='text-green-500 font-bold'>public</span>.",
"rebuild_application": "Re-build l'application", "rebuild_application": "Re-build l'application",
"secret": "secrets", "secret": "secrets",
"secrets": { "secrets": {
@ -91,7 +91,7 @@
"use_isbuildsecret": "Utiliser isBuildSecret" "use_isbuildsecret": "Utiliser isBuildSecret"
}, },
"settings_saved": "Paramètres sauvegardés.", "settings_saved": "Paramètres sauvegardés.",
"ssl_explainer": "Il générera des certificats pour www et non-www. \n<br>Vous devez avoir <span class='font-bold text-applications'>les deux entrées DNS</span> définies à l'avance.<br><br>Utile si vous prévoyez d'avoir des visiteurs sur les deux.", "ssl_explainer": "Il générera des certificats pour www et non-www. \n<br>Vous devez avoir <span class='font-bold text-green-500'>les deux entrées DNS</span> définies à l'avance.<br><br>Utile si vous prévoyez d'avoir des visiteurs sur les deux.",
"ssl_www_and_non_www": "Générer SSL pour www et non-www ?", "ssl_www_and_non_www": "Générer SSL pour www et non-www ?",
"start_command": "Démarrer la commande", "start_command": "Démarrer la commande",
"stop_application": "Arrêter l'application", "stop_application": "Arrêter l'application",

View File

@ -1,3 +1,4 @@
import { dev } from '$app/env';
import cuid from 'cuid'; import cuid from 'cuid';
import { writable, readable, type Writable } from 'svelte/store'; import { writable, readable, type Writable } from 'svelte/store';
@ -70,7 +71,11 @@ export const features = readable({
}); });
export const location: Writable<null | string> = writable(null) export const location: Writable<null | string> = writable(null)
export const setLocation = (resource: any) => { export const setLocation = (resource: any, settings?: any) => {
if (resource.settings.isBot && resource.exposePort) {
disabledButton.set(false);
return location.set(`http://${dev ? 'localhost' : settings.ipv4}:${resource.exposePort}`)
}
if (GITPOD_WORKSPACE_URL && resource.exposePort) { if (GITPOD_WORKSPACE_URL && resource.exposePort) {
const { href } = new URL(GITPOD_WORKSPACE_URL); const { href } = new URL(GITPOD_WORKSPACE_URL);
const newURL = href const newURL = href
@ -81,7 +86,12 @@ export const setLocation = (resource: any) => {
const newURL = `https://${CODESANDBOX_HOST.replace(/\$PORT/, resource.exposePort)}` const newURL = `https://${CODESANDBOX_HOST.replace(/\$PORT/, resource.exposePort)}`
return location.set(newURL) return location.set(newURL)
} }
if (resource.fqdn) {
return location.set(resource.fqdn) return location.set(resource.fqdn)
} else {
location.set(null);
disabledButton.set(true);
}
} }
export const toasts: any = writable([]) export const toasts: any = writable([])

View File

@ -16,7 +16,7 @@
export const load: Load = async ({ fetch, url, params }) => { export const load: Load = async ({ fetch, url, params }) => {
try { try {
const response = await get(`/applications/${params.id}`); const response = await get(`/applications/${params.id}`);
let { application, appId, settings, isQueueActive } = response; let { application, appId, settings } = response;
if (!application || Object.entries(application).length === 0) { if (!application || Object.entries(application).length === 0) {
return { return {
status: 302, status: 302,
@ -36,7 +36,8 @@
return { return {
props: { props: {
application application,
settings
}, },
stuff: { stuff: {
application, application,
@ -52,7 +53,7 @@
<script lang="ts"> <script lang="ts">
export let application: any; export let application: any;
export let settings: any;
import { page } from '$app/stores'; import { page } from '$app/stores';
import DeleteIcon from '$lib/components/DeleteIcon.svelte'; import DeleteIcon from '$lib/components/DeleteIcon.svelte';
import { del, get, post } from '$lib/api'; import { del, get, post } from '$lib/api';
@ -68,7 +69,7 @@
let isQueueActive = false; let isQueueActive = false;
$disabledButton = $disabledButton =
!$appSession.isAdmin || !$appSession.isAdmin ||
!application.fqdn || (!application.fqdn && !application.settings.isBot) ||
!application.gitSource || !application.gitSource ||
!application.repository || !application.repository ||
!application.destinationDocker || !application.destinationDocker ||
@ -126,18 +127,23 @@
$status.application.loading = false; $status.application.loading = false;
$status.application.initialLoading = false; $status.application.initialLoading = false;
} }
onDestroy(() => { onDestroy(() => {
$status.application.initialLoading = true; $status.application.initialLoading = true;
$location = null; $location = null;
clearInterval(statusInterval); clearInterval(statusInterval);
}); });
onMount(async () => { onMount(async () => {
setLocation(application); setLocation(application, settings);
$status.application.isRunning = false; $status.application.isRunning = false;
$status.application.isExited = false; $status.application.isExited = false;
$status.application.loading = false; $status.application.loading = false;
if (application.gitSourceId && application.destinationDockerId && application.fqdn) { if (
application.gitSourceId &&
application.destinationDockerId &&
(application.fqdn ||
application.settings.isBot)
) {
await getStatus(); await getStatus();
statusInterval = setInterval(async () => { statusInterval = setInterval(async () => {
await getStatus(); await getStatus();
@ -258,7 +264,7 @@
class="icons bg-transparent tooltip tooltip-primary tooltip-bottom text-sm flex items-center space-x-2" class="icons bg-transparent tooltip tooltip-primary tooltip-bottom text-sm flex items-center space-x-2"
data-tip={$appSession.isAdmin data-tip={$appSession.isAdmin
? isQueueActive ? isQueueActive
? 'Rebuild application' ? 'Rebuild Application'
: 'Autoupdate inprogress. Cannot rebuild application.' : 'Autoupdate inprogress. Cannot rebuild application.'
: 'You do not have permission to rebuild application.'} : 'You do not have permission to rebuild application.'}
> >
@ -403,6 +409,7 @@
</svg> </svg>
</button></a </button></a
> >
{#if !application.settings.isBot}
<a <a
href={!$disabledButton ? `/applications/${id}/previews` : null} href={!$disabledButton ? `/applications/${id}/previews` : null}
sveltekit:prefetch sveltekit:prefetch
@ -434,6 +441,7 @@
</svg></button </svg></button
></a ></a
> >
{/if}
<div class="border border-coolgray-500 h-8" /> <div class="border border-coolgray-500 h-8" />
<a <a
href={!$disabledButton && $status.application.isRunning ? `/applications/${id}/logs` : null} href={!$disabledButton && $status.application.isRunning ? `/applications/${id}/logs` : null}

View File

@ -5,7 +5,8 @@
if (stuff?.application?.id) { if (stuff?.application?.id) {
return { return {
props: { props: {
application: stuff.application application: stuff.application,
settings: stuff.settings
} }
}; };
} }
@ -26,6 +27,7 @@
<script lang="ts"> <script lang="ts">
export let application: any; export let application: any;
export let settings: any;
import { page } from '$app/stores'; import { page } from '$app/stores';
import { onDestroy, onMount } from 'svelte'; import { onDestroy, onMount } from 'svelte';
import Select from 'svelte-select'; import Select from 'svelte-select';
@ -60,6 +62,7 @@
let previews = application.settings.previews; let previews = application.settings.previews;
let dualCerts = application.settings.dualCerts; let dualCerts = application.settings.dualCerts;
let autodeploy = application.settings.autodeploy; let autodeploy = application.settings.autodeploy;
let isBot = application.settings.isBot;
let nonWWWDomain = application.fqdn && getDomain(application.fqdn).replace(/^www\./, ''); let nonWWWDomain = application.fqdn && getDomain(application.fqdn).replace(/^www\./, '');
let isNonWWWDomainOK = false; let isNonWWWDomainOK = false;
@ -99,7 +102,7 @@
application.fqdn = `http://${cuid()}.demo.coolify.io`; application.fqdn = `http://${cuid()}.demo.coolify.io`;
await handleSubmit(); await handleSubmit();
} }
domainEl.focus(); // !isBot && domainEl.focus();
await getUsage(); await getUsage();
usageInterval = setInterval(async () => { usageInterval = setInterval(async () => {
await getUsage(); await getUsage();
@ -129,11 +132,17 @@
if (name === 'autodeploy') { if (name === 'autodeploy') {
autodeploy = !autodeploy; autodeploy = !autodeploy;
} }
if (name === 'isBot') {
isBot = !isBot;
application.settings.isBot = isBot;
setLocation(application, settings);
}
try { try {
await post(`/applications/${id}/settings`, { await post(`/applications/${id}/settings`, {
previews, previews,
debug, debug,
dualCerts, dualCerts,
isBot,
autodeploy, autodeploy,
branch: application.branch, branch: application.branch,
projectId: application.projectId projectId: application.projectId
@ -155,24 +164,28 @@
if (name === 'autodeploy') { if (name === 'autodeploy') {
autodeploy = !autodeploy; autodeploy = !autodeploy;
} }
if (name === 'isBot') {
isBot = !isBot;
}
return errorNotification(error); return errorNotification(error);
} }
} }
async function handleSubmit() { async function handleSubmit() {
if (loading || !application.fqdn) return; if (loading || (!application.fqdn && !isBot)) return;
loading = true; loading = true;
try { try {
nonWWWDomain = application.fqdn && getDomain(application.fqdn).replace(/^www\./, ''); nonWWWDomain = application.fqdn && getDomain(application.fqdn).replace(/^www\./, '');
if (application.deploymentType) if (application.deploymentType)
application.deploymentType = application.deploymentType.toLowerCase(); application.deploymentType = application.deploymentType.toLowerCase();
await post(`/applications/${id}/check`, { !isBot &&
(await post(`/applications/${id}/check`, {
fqdn: application.fqdn, fqdn: application.fqdn,
forceSave, forceSave,
dualCerts, dualCerts,
exposePort: application.exposePort exposePort: application.exposePort
}); }));
await post(`/applications/${id}`, { ...application }); await post(`/applications/${id}`, { ...application });
setLocation(application); setLocation(application, settings);
$disabledButton = false; $disabledButton = false;
forceSave = false; forceSave = false;
addToast({ addToast({
@ -468,6 +481,16 @@
<div class="title">{$t('application.application')}</div> <div class="title">{$t('application.application')}</div>
</div> </div>
<div class="grid grid-flow-row gap-2 px-10"> <div class="grid grid-flow-row gap-2 px-10">
<div class="grid grid-cols-2 items-center">
<Setting
isCenter={false}
bind:setting={isBot}
on:click={() => changeSettings('isBot')}
title="Is your application a bot?"
description="You can deploy applications without domains. <br>They will listen on <span class='text-green-500 font-bold'>IP:EXPOSEDPORT</span> instead.<br></Setting><br>Useful to host <span class='text-green-500 font-bold'>Twitch bots.</span>"
/>
</div>
{#if !isBot}
<div class="grid grid-cols-2"> <div class="grid grid-cols-2">
<div class="flex-col"> <div class="flex-col">
<label for="fqdn" class="pt-2 text-base font-bold text-stone-100" <label for="fqdn" class="pt-2 text-base font-bold text-stone-100"
@ -496,13 +519,13 @@
<div class="flex-col space-y-2 pt-4 text-center"> <div class="flex-col space-y-2 pt-4 text-center">
{#if isNonWWWDomainOK} {#if isNonWWWDomainOK}
<button <button
class="bg-green-600 hover:bg-green-500" class="btn btn-sm bg-green-600 hover:bg-green-500"
on:click|preventDefault={() => isDNSValid(getDomain(nonWWWDomain), false)} on:click|preventDefault={() => isDNSValid(getDomain(nonWWWDomain), false)}
>DNS settings for {nonWWWDomain} is OK, click to recheck.</button >DNS settings for {nonWWWDomain} is OK, click to recheck.</button
> >
{:else} {:else}
<button <button
class="bg-red-600 hover:bg-red-500" class="btn btn-sm bg-red-600 hover:bg-red-500"
on:click|preventDefault={() => isDNSValid(getDomain(nonWWWDomain), false)} on:click|preventDefault={() => isDNSValid(getDomain(nonWWWDomain), false)}
>DNS settings for {nonWWWDomain} is invalid, click to recheck.</button >DNS settings for {nonWWWDomain} is invalid, click to recheck.</button
> >
@ -510,14 +533,14 @@
{#if dualCerts} {#if dualCerts}
{#if isWWWDomainOK} {#if isWWWDomainOK}
<button <button
class="bg-green-600 hover:bg-green-500" class="btn btn-sm bg-green-600 hover:bg-green-500"
on:click|preventDefault={() => on:click|preventDefault={() =>
isDNSValid(getDomain(`www.${nonWWWDomain}`), true)} isDNSValid(getDomain(`www.${nonWWWDomain}`), true)}
>DNS settings for www.{nonWWWDomain} is OK, click to recheck.</button >DNS settings for www.{nonWWWDomain} is OK, click to recheck.</button
> >
{:else} {:else}
<button <button
class="bg-red-600 hover:bg-red-500" class="btn btn-sm bg-red-600 hover:bg-red-500"
on:click|preventDefault={() => on:click|preventDefault={() =>
isDNSValid(getDomain(`www.${nonWWWDomain}`), true)} isDNSValid(getDomain(`www.${nonWWWDomain}`), true)}
>DNS settings for www.{nonWWWDomain} is invalid, click to recheck.</button >DNS settings for www.{nonWWWDomain} is invalid, click to recheck.</button
@ -539,6 +562,7 @@
on:click={() => !$status.application.isRunning && changeSettings('dualCerts')} on:click={() => !$status.application.isRunning && changeSettings('dualCerts')}
/> />
</div> </div>
{/if}
{#if application.buildPack === 'python'} {#if application.buildPack === 'python'}
<div class="grid grid-cols-2 items-center"> <div class="grid grid-cols-2 items-center">
<label for="pythonModule" class="text-base font-bold text-stone-100">WSGI / ASGI</label> <label for="pythonModule" class="text-base font-bold text-stone-100">WSGI / ASGI</label>
@ -588,7 +612,7 @@
</div> </div>
{/if} {/if}
{/if} {/if}
{#if !staticDeployments.includes(application.buildPack)} {#if !staticDeployments.includes(application.buildPack) && !isBot}
<div class="grid grid-cols-2 items-center"> <div class="grid grid-cols-2 items-center">
<label for="port" class="text-base font-bold text-stone-100">{$t('forms.port')}</label> <label for="port" class="text-base font-bold text-stone-100">{$t('forms.port')}</label>
<input <input
@ -609,6 +633,7 @@
name="exposePort" name="exposePort"
id="exposePort" id="exposePort"
bind:value={application.exposePort} bind:value={application.exposePort}
required={isBot}
placeholder="12345" placeholder="12345"
/> />
<Explainer <Explainer
@ -754,6 +779,7 @@
description={$t('application.enable_auto_deploy_webhooks')} description={$t('application.enable_auto_deploy_webhooks')}
/> />
</div> </div>
{#if !application.settings.isBot}
<div class="grid grid-cols-2 items-center"> <div class="grid grid-cols-2 items-center">
<Setting <Setting
isCenter={false} isCenter={false}
@ -763,6 +789,7 @@
description={$t('application.enable_preview_deploy_mr_pr_requests')} description={$t('application.enable_preview_deploy_mr_pr_requests')}
/> />
</div> </div>
{/if}
<div class="grid grid-cols-2 items-center"> <div class="grid grid-cols-2 items-center">
<Setting <Setting
isCenter={false} isCenter={false}

View File

@ -87,6 +87,9 @@
{#if application.fqdn} {#if application.fqdn}
<div class="truncate text-center">{getDomain(application.fqdn) || ''}</div> <div class="truncate text-center">{getDomain(application.fqdn) || ''}</div>
{/if} {/if}
{#if application.settings.isBot}
<div class="truncate text-center">BOT</div>
{/if}
{#if application.destinationDocker?.name} {#if application.destinationDocker?.name}
<div class="truncate text-center">{application.destinationDocker.name}</div> <div class="truncate text-center">{application.destinationDocker.name}</div>
{/if} {/if}
@ -98,7 +101,7 @@
<div class="truncate text-center font-bold text-red-500 group-hover:text-white"> <div class="truncate text-center font-bold text-red-500 group-hover:text-white">
Destination Missing Destination Missing
</div> </div>
{:else if !application.fqdn} {:else if !application.fqdn && !application.settings.isBot}
<div class="truncate text-center font-bold text-red-500 group-hover:text-white"> <div class="truncate text-center font-bold text-red-500 group-hover:text-white">
URL Missing URL Missing
</div> </div>

View File

@ -20,6 +20,11 @@
</script> </script>
<script lang="ts"> <script lang="ts">
export let applications: any;
export let databases: any;
export let services: any;
export let settings: any;
import { get, post } from '$lib/api'; import { get, post } from '$lib/api';
import Usage from '$lib/components/Usage.svelte'; import Usage from '$lib/components/Usage.svelte';
import { t } from '$lib/translations'; import { t } from '$lib/translations';
@ -29,14 +34,12 @@
import ApplicationsIcons from '$lib/components/svg/applications/ApplicationIcons.svelte'; import ApplicationsIcons from '$lib/components/svg/applications/ApplicationIcons.svelte';
import DatabaseIcons from '$lib/components/svg/databases/DatabaseIcons.svelte'; import DatabaseIcons from '$lib/components/svg/databases/DatabaseIcons.svelte';
import ServiceIcons from '$lib/components/svg/services/ServiceIcons.svelte'; import ServiceIcons from '$lib/components/svg/services/ServiceIcons.svelte';
import { dev } from '$app/env';
let loading = { let loading = {
cleanup: false cleanup: false
}; };
export let applications: any;
export let databases: any;
export let services: any;
let numberOfGetStatus = 0; let numberOfGetStatus = 0;
function getRndInteger(min: number, max: number) { function getRndInteger(min: number, max: number) {
@ -121,8 +124,13 @@
<ApplicationsIcons {application} isAbsolute={false} /> <ApplicationsIcons {application} isAbsolute={false} />
</td> </td>
<td class="px-10"> <td class="px-10">
<div class="badge badge-outline text-xs border-applications rounded text-white"> <div
class="badge badge-outline text-xs border-applications rounded text-white"
>
Application Application
{#if application.settings.isBot}
| BOT
{/if}
</div></td </div></td
> >
<td class="flex justify-end"> <td class="flex justify-end">
@ -148,6 +156,30 @@
</svg></a </svg></a
> >
{/if} {/if}
{#if application.settings.isBot && application.exposePort}
<a
href={`http://${dev ? 'localhost' : settings.ipv4}:${
application.exposePort
}`}
target="_blank"
class="icons bg-transparent text-sm inline-flex"
><svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M11 7h-5a2 2 0 0 0 -2 2v9a2 2 0 0 0 2 2h9a2 2 0 0 0 2 -2v-5" />
<line x1="10" y1="14" x2="20" y2="4" />
<polyline points="15 4 20 4 20 9" />
</svg></a
>
{/if}
<a <a
href={`/applications/${application.id}`} href={`/applications/${application.id}`}
class="icons bg-transparent text-sm inline-flex" class="icons bg-transparent text-sm inline-flex"

View File

@ -322,13 +322,13 @@
<div class="flex-col space-y-2 pt-4 text-center"> <div class="flex-col space-y-2 pt-4 text-center">
{#if isNonWWWDomainOK} {#if isNonWWWDomainOK}
<button <button
class="bg-green-600 hover:bg-green-500" class="btn btn-sm bg-green-600 hover:bg-green-500"
on:click|preventDefault={() => isDNSValid(getDomain(nonWWWDomain), false)} on:click|preventDefault={() => isDNSValid(getDomain(nonWWWDomain), false)}
>DNS settings for {nonWWWDomain} is OK, click to recheck.</button >DNS settings for {nonWWWDomain} is OK, click to recheck.</button
> >
{:else} {:else}
<button <button
class="bg-red-600 hover:bg-red-500" class="btn btn-sm bg-red-600 hover:bg-red-500"
on:click|preventDefault={() => isDNSValid(getDomain(nonWWWDomain), false)} on:click|preventDefault={() => isDNSValid(getDomain(nonWWWDomain), false)}
>DNS settings for {nonWWWDomain} is invalid, click to recheck.</button >DNS settings for {nonWWWDomain} is invalid, click to recheck.</button
> >
@ -336,13 +336,13 @@
{#if dualCerts} {#if dualCerts}
{#if isWWWDomainOK} {#if isWWWDomainOK}
<button <button
class="bg-green-600 hover:bg-green-500" class="btn btn-sm bg-green-600 hover:bg-green-500"
on:click|preventDefault={() => isDNSValid(getDomain(`www.${nonWWWDomain}`), true)} on:click|preventDefault={() => isDNSValid(getDomain(`www.${nonWWWDomain}`), true)}
>DNS settings for www.{nonWWWDomain} is OK, click to recheck.</button >DNS settings for www.{nonWWWDomain} is OK, click to recheck.</button
> >
{:else} {:else}
<button <button
class="bg-red-600 hover:bg-red-500" class="btn btn-sm bg-red-600 hover:bg-red-500"
on:click|preventDefault={() => isDNSValid(getDomain(`www.${nonWWWDomain}`), true)} on:click|preventDefault={() => isDNSValid(getDomain(`www.${nonWWWDomain}`), true)}
>DNS settings for www.{nonWWWDomain} is invalid, click to recheck.</button >DNS settings for www.{nonWWWDomain} is invalid, click to recheck.</button
> >

View File

@ -31,7 +31,7 @@
let dualCerts = settings.dualCerts; let dualCerts = settings.dualCerts;
let isAutoUpdateEnabled = settings.isAutoUpdateEnabled; let isAutoUpdateEnabled = settings.isAutoUpdateEnabled;
let isDNSCheckEnabled = settings.isDNSCheckEnabled; let isDNSCheckEnabled = settings.isDNSCheckEnabled;
let DNSServers = settings.DNSServers;
let minPort = settings.minPort; let minPort = settings.minPort;
let maxPort = settings.maxPort; let maxPort = settings.maxPort;
@ -105,6 +105,10 @@
settings.minPort = minPort; settings.minPort = minPort;
settings.maxPort = maxPort; settings.maxPort = maxPort;
} }
if (DNSServers !== settings.DNSServers) {
await post(`/settings`, { DNSServers });
settings.DNSServers = DNSServers;
}
forceSave = false; forceSave = false;
return addToast({ return addToast({
message: 'Configuration saved.', message: 'Configuration saved.',
@ -275,6 +279,17 @@
on:click={() => changeSettings('isDNSCheckEnabled')} on:click={() => changeSettings('isDNSCheckEnabled')}
/> />
</div> </div>
<div class="grid grid-cols-2 items-center">
<div class="flex-col">
<div class="pt-2 text-base font-bold text-stone-100">
Custom DNS servers
</div>
<Explainer text="You can specify a custom DNS server to verify your domains all over Coolify.<br><br>By default, the OS defined DNS servers are used." />
</div>
<div class="mx-auto flex-row items-center justify-center space-y-2">
<input placeholder="1.1.1.1,8.8.8.8" bind:value={DNSServers} />
</div>
</div>
<div class="grid grid-cols-2 items-center"> <div class="grid grid-cols-2 items-center">
<Setting <Setting
dataTooltip={$t('setting.must_remove_domain_before_changing')} dataTooltip={$t('setting.must_remove_domain_before_changing')}

View File

@ -1,7 +1,7 @@
{ {
"name": "coolify", "name": "coolify",
"description": "An open-source & self-hostable Heroku / Netlify alternative.", "description": "An open-source & self-hostable Heroku / Netlify alternative.",
"version": "3.4.0", "version": "3.5.0",
"license": "Apache-2.0", "license": "Apache-2.0",
"repository": "github:coollabsio/coolify", "repository": "github:coollabsio/coolify",
"scripts": { "scripts": {