Compare commits

...

8 commits

81 changed files with 1699 additions and 2284 deletions

View file

@ -15,4 +15,5 @@ ROOT_USERNAME=
ROOT_USER_EMAIL=
ROOT_USER_PASSWORD=
REGISTRY_URL=docker.io
REGISTRY_URL=forgejo.mapledeploy.ca
CDN_URL=https://updates.mapledeploy.ca

View file

@ -0,0 +1,131 @@
name: Build MapleDeploy Coolify Image
on:
push:
branches: [mapledeploy]
paths-ignore:
- "*.md"
- ".github/**"
env:
REGISTRY: forgejo.mapledeploy.ca
CDN_STORAGE_ZONE: coolify-update
CDN_PULL_ZONE_ID: "5338895"
CDN_BASE_URL: https://updates.mapledeploy.ca
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Get version
id: version
run: |
# Reads one version constant out of config/constants.php.
# The match is anchored at the start of the line, so a nested key on
# another line (e.g. "'nightly' => ['version' => '5.9.9']") cannot be
# picked up, and the trailing greedy .* takes the LAST quoted string on
# the line, so both upstream shapes work:
# 'version' => '4.2.0',
# 'version' => env('COOLIFY_VERSION') ?: '4.3.12',
# Empty or non-version results fail the build instead of tagging the
# image and versions.json with garbage.
extract_version() {
key="$1"
value=$(sed -n "s/^[[:space:]]*'${key}' => .*'\([^']*\)'.*/\1/p" config/constants.php | head -1)
if [ -z "$value" ]; then
echo "ERROR: could not extract '${key}' from config/constants.php" >&2
grep -n "'${key}' =>" config/constants.php >&2 || true
exit 1
fi
if ! printf '%s' "$value" | grep -Eq '^[0-9]+(\.[0-9]+)+([-.][0-9A-Za-z.]+)?$'; then
echo "ERROR: extracted ${key} '${value}' does not look like a version." >&2
echo " The '${key}' entry in config/constants.php probably changed shape;" >&2
echo " see docs/operations/COOLIFY_FORK.md (How version bumping works)." >&2
exit 1
fi
printf '%s' "$value"
}
# `exit 1` inside the function only leaves the command substitution's
# subshell, so each call needs its own `|| exit 1`.
BASE_VERSION=$(extract_version version) || exit 1
HELPER_VERSION=$(extract_version helper_version) || exit 1
REALTIME_VERSION=$(extract_version realtime_version) || exit 1
TIMESTAMP=$(date -u +%Y%m%d%H%M)
VERSION="${BASE_VERSION}.${TIMESTAMP}"
echo "VERSION=${VERSION}" >> "$GITHUB_OUTPUT"
echo "HELPER_VERSION=${HELPER_VERSION}" >> "$GITHUB_OUTPUT"
echo "REALTIME_VERSION=${REALTIME_VERSION}" >> "$GITHUB_OUTPUT"
echo "Building version: ${VERSION} (helper: ${HELPER_VERSION}, realtime: ${REALTIME_VERSION})"
- name: Login to Forgejo registry
run: |
echo "${{ secrets.FORGEJO_TOKEN }}" | docker login ${{ env.REGISTRY }} -u ${{ github.repository_owner }} --password-stdin
- name: Build image
run: |
DOCKER_BUILDKIT=1 docker build -f docker/production/Dockerfile \
--build-arg MAPLEDEPLOY_VERSION=${{ steps.version.outputs.VERSION }} \
-t ${{ env.REGISTRY }}/${{ github.repository }}:${{ steps.version.outputs.VERSION }} \
-t ${{ env.REGISTRY }}/${{ github.repository }}:latest \
.
- name: Push image
run: |
docker push ${{ env.REGISTRY }}/${{ github.repository }}:${{ steps.version.outputs.VERSION }}
docker push ${{ env.REGISTRY }}/${{ github.repository }}:latest
- name: Generate versions.json
run: |
cat > versions.json <<EOF
{
"coolify": {
"v4": {
"version": "${{ steps.version.outputs.VERSION }}"
},
"helper": {
"version": "${{ steps.version.outputs.HELPER_VERSION }}"
},
"realtime": {
"version": "${{ steps.version.outputs.REALTIME_VERSION }}"
}
}
}
EOF
echo "Generated versions.json:"
cat versions.json
- name: Install curl
run: apk add --no-cache curl
- name: Upload artifacts to Bunny CDN
run: |
STORAGE_URL="https://storage.bunnycdn.com/${{ env.CDN_STORAGE_ZONE }}/coolify"
upload() {
local file="$1"
local dest="$2"
echo "Uploading ${file} -> ${dest}"
curl -fsSL -X PUT "${STORAGE_URL}/${dest}" \
-H "AccessKey: ${{ secrets.BUNNY_CDN_STORAGE_KEY }}" \
-H "Content-Type: application/octet-stream" \
--data-binary @"${file}"
}
upload versions.json versions.json
upload scripts/upgrade.sh upgrade.sh
upload scripts/upgrade-postgres.sh upgrade-postgres.sh
upload docker-compose.yml docker-compose.yml
upload docker-compose.prod.yml docker-compose.prod.yml
upload .env.production .env.production
echo "All artifacts uploaded."
- name: Purge CDN cache
run: |
curl -fsSL -X POST "https://api.bunny.net/pullzone/${{ env.CDN_PULL_ZONE_ID }}/purgeCache" \
-H "AccessKey: ${{ secrets.BUNNY_API_KEY }}" \
-H "Content-Type: application/json"
echo "CDN cache purged."

View file

@ -1,22 +0,0 @@
name: Lock closed Issues, Discussions, and PRs
on:
schedule:
- cron: '0 1 * * *'
permissions:
issues: write
discussions: write
pull-requests: write
jobs:
lock-threads:
runs-on: ubuntu-latest
steps:
- name: Lock threads after 30 days of inactivity
uses: dessant/lock-threads@89ae32b08ed1a541efecbab17912962a5e38981c # v6.0.2
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
issue-inactive-days: '30'
discussion-inactive-days: '30'
pr-inactive-days: '30'

View file

@ -1,182 +0,0 @@
name: Manage PR Branch
# Runs *after* the "PR Quality" workflow finishes. This is required because
# PR Quality may close a PR that fails its checks, so we must wait for it to
# complete before deciding whether to retarget the PR's base branch.
on:
workflow_run:
workflows: ["PR Quality"]
types:
- completed
permissions:
contents: read
pull-requests: write
concurrency:
group: manage-pr-branch-${{ github.event.workflow_run.head_sha }}
cancel-in-progress: true
jobs:
manage-branch:
runs-on: ubuntu-latest
steps:
- name: Retarget PR base branch based on category
uses: actions/github-script@v7
with:
script: |
const run = context.payload.workflow_run;
// Branch routing based on the "Category" section of the PR body.
// Bug fixes and one-click service changes ship in patch releases -> main.
// Everything else (features, improvements) -> next.
const MAIN_BRANCH = 'main';
const NEXT_BRANCH = 'next';
// Maintainers/collaborators are trusted to pick their own base branch.
const EXEMPT_ASSOCIATIONS = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']);
// Resolve the open PR from the triggering run.
//
// PR Quality runs on `pull_request_target`, so `run.head_sha` is the
// *base* branch tip, not the PR head — a commit-based lookup finds
// nothing. Instead match on the source branch (`head_branch`) and its
// owner (`head_repository.owner.login`), which uniquely identify the PR
// via the `owner:branch` head filter. This also works for forked PRs,
// where `workflow_run.pull_requests` is empty.
const headOwner = run.head_repository?.owner?.login;
const headBranch = run.head_branch;
let prRef;
if (headOwner && headBranch) {
const { data: openPrs } = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
head: `${headOwner}:${headBranch}`,
per_page: 100,
});
prRef = openPrs[0];
}
// Fallback: same-repo PRs may also be resolvable by commit association.
if (!prRef) {
const { data: associated } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
owner: context.repo.owner,
repo: context.repo.repo,
commit_sha: run.head_sha,
});
prRef = associated.find(pr => pr.state === 'open');
}
if (!prRef) {
core.info('No open PR associated with this run (possibly closed by PR Quality). Skipping.');
return;
}
// Fetch the full PR to get an up-to-date body, base ref, and state.
const { data: pr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prRef.number,
});
if (pr.state !== 'open') {
core.info(`PR #${pr.number} is not open. Skipping.`);
return;
}
// Skip PRs opened by owners/members/collaborators — they choose their own base.
if (EXEMPT_ASSOCIATIONS.has(pr.author_association)) {
core.info(`PR #${pr.number} author association is ${pr.author_association}. Skipping.`);
return;
}
// Skip if a maintainer has already changed the base branch manually.
const timeline = await github.paginate(github.rest.issues.listEventsForTimeline, {
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
per_page: 100,
});
const baseChanges = timeline.filter(e => e.event === 'base_ref_changed');
for (const change of baseChanges) {
const actor = change.actor?.login;
if (!actor) {
continue;
}
try {
const { data: perm } = await github.rest.repos.getCollaboratorPermissionLevel({
owner: context.repo.owner,
repo: context.repo.repo,
username: actor,
});
// admin/maintain/write => trusted maintainer.
if (['admin', 'maintain', 'write'].includes(perm.permission)) {
core.info(`Base branch was changed manually by ${actor} (${perm.permission}). Skipping.`);
return;
}
} catch (error) {
core.info(`Could not resolve permission for ${actor}: ${error.message}`);
}
}
// Parse the checked category checkboxes from the PR body.
const body = pr.body ?? '';
const checked = [];
const checkboxRegex = /^\s*-\s*\[([ xX])\]\s*(.+?)\s*$/gm;
let match;
while ((match = checkboxRegex.exec(body)) !== null) {
if (match[1].toLowerCase() === 'x') {
checked.push(match[2].toLowerCase());
}
}
const includesAny = (labels) => labels.some(label => checked.some(c => c.includes(label)));
const mainCategories = ['bug fix', 'adding new one click service', 'fixing or updating existing one click service'];
const nextCategories = ['improvement', 'new feature'];
const wantsMain = includesAny(mainCategories);
const wantsNext = includesAny(nextCategories);
if (!wantsMain && !wantsNext) {
core.info('No category selected in the PR body. Skipping.');
return;
}
// If categories from both groups are checked, prefer next: features and
// improvements can only be released from the development branch.
const targetBranch = wantsNext ? NEXT_BRANCH : MAIN_BRANCH;
if (pr.base.ref === targetBranch) {
core.info(`PR #${pr.number} already targets ${targetBranch}. Nothing to do.`);
return;
}
const previousBranch = pr.base.ref;
await github.rest.pulls.update({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pr.number,
base: targetBranch,
});
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
body: [
`Based on the selected category, this PR's base branch was automatically changed from \`${previousBranch}\` to \`${targetBranch}\`.`,
'',
targetBranch === MAIN_BRANCH
? 'Bug fixes and one-click service changes target `main`.'
: 'New features and improvements target `next`.',
'',
'If you believe this is incorrect, please let a maintainer know.',
].join('\n'),
});
core.info(`Retargeted PR #${pr.number}: ${previousBranch} -> ${targetBranch}.`);

View file

@ -1,32 +0,0 @@
name: Manage Stale Issues and PRs
on:
schedule:
- cron: '0 2 * * *'
permissions:
issues: write
pull-requests: write
jobs:
manage-stale:
runs-on: ubuntu-latest
steps:
- name: Manage stale issues and PRs
uses: actions/stale@v9
id: stale
with:
stale-issue-message: 'This issue will be automatically closed in a few days if no response is received. Please provide an update with the requested information.'
stale-pr-message: 'This pull request requires attention. If no changes or response is received within the next few days, it will be automatically closed. Please update your PR or leave a comment with the requested information.'
close-issue-message: 'This issue has been automatically closed due to inactivity.'
close-pr-message: 'Thank you for your contribution. Due to inactivity, this PR was automatically closed. If you would like to continue working on this change in the future, feel free to reopen this PR or submit a new one.'
days-before-stale: 14
days-before-close: 7
stale-issue-label: '⏱︎ Stale'
stale-pr-label: '⏱︎ Stale'
only-labels: '💤 Waiting for feedback, 💤 Waiting for changes'
remove-stale-when-updated: true
operations-per-run: 100
labels-to-remove-when-unstale: '⏱︎ Stale, 💤 Waiting for feedback, 💤 Waiting for changes'
close-issue-reason: 'not_planned'
exempt-all-milestones: false

View file

@ -1,52 +0,0 @@
name: Add comment based on label
on:
pull_request_target:
types:
- labeled
permissions:
pull-requests: write
jobs:
add-comment:
runs-on: ubuntu-latest
strategy:
matrix:
include:
- label: "⚙️ Service"
body: |
Hi @${{ github.event.pull_request.user.login }}! 👋
It appears to us that you are either adding a new service or making changes to an existing one.
We kindly ask you to also review and update the **Coolify Documentation** to include this new service or it's new configuration needs.
This will help ensure that our documentation remains accurate and up-to-date for all users.
Coolify Docs Repository: https://github.com/coollabsio/coolify-docs
How to Contribute a new Service to the Docs: https://coolify.io/docs/get-started/contribute/service#adding-a-new-service-template-to-the-coolify-documentation
- label: "🛠️ Feature"
body: |
Hi @${{ github.event.pull_request.user.login }}! 👋
It appears to us that you are adding a new feature to Coolify.
We kindly ask you to also update the **Coolify Documentation** to include information about this new feature.
This will help ensure that our documentation remains accurate and up-to-date for all users.
Coolify Docs Repository: https://github.com/coollabsio/coolify-docs
How to Contribute to the Docs: https://coolify.io/docs/get-started/contribute/documentation
# - label: "✨ Enhancement"
# body: |
# It appears to us that you are making an enhancement to Coolify.
# We kindly ask you to also review and update the Coolify Documentation to include information about this enhancement if applicable.
# This will help ensure that our documentation remains accurate and up-to-date for all users.
steps:
- name: Add comment
if: >-
(github.event.label.name == matrix.label || github.event.label.name == '📑 Waiting for Docs PR')
&& contains(github.event.pull_request.labels.*.name, matrix.label)
&& contains(github.event.pull_request.labels.*.name, '📑 Waiting for Docs PR')
run: gh pr comment "$NUMBER" --body "$BODY"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
NUMBER: ${{ github.event.pull_request.number }}
BODY: ${{ matrix.body }}

View file

@ -1,37 +0,0 @@
name: Claude Code
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
jobs:
claude:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
issues: write
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
claude_args: '--model opus'

View file

@ -1,22 +0,0 @@
name: Cleanup Untagged GHCR Images
on:
workflow_dispatch:
permissions:
packages: write
jobs:
cleanup-all-packages:
runs-on: ubuntu-latest
strategy:
matrix:
package: ['coolify', 'coolify-helper', 'coolify-realtime', 'coolify-testing-host']
steps:
- name: Delete untagged ${{ matrix.package }} images
uses: actions/delete-package-versions@v5
with:
package-name: ${{ matrix.package }}
package-type: 'container'
min-versions-to-keep: 0
delete-only-untagged-versions: 'true'

View file

@ -1,117 +0,0 @@
name: Coolify Helper Image Development
on:
push:
branches: [ "next" ]
paths:
- .github/workflows/coolify-helper-next.yml
- docker/coolify-helper/Dockerfile
permissions:
contents: read
packages: write
env:
GITHUB_REGISTRY: ghcr.io
DOCKER_REGISTRY: docker.io
IMAGE_NAME: "coollabsio/coolify-helper"
jobs:
build-push:
strategy:
matrix:
include:
- arch: amd64
platform: linux/amd64
runner: ubuntu-24.04
- arch: aarch64
platform: linux/aarch64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.GITHUB_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to ${{ env.DOCKER_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Get Version
id: version
run: |
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getHelperVersion.php)"|xargs >> $GITHUB_OUTPUT
- name: Build and Push Image (${{ matrix.arch }})
uses: docker/build-push-action@v6
with:
context: .
file: docker/coolify-helper/Dockerfile
platforms: ${{ matrix.platform }}
push: true
tags: |
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-${{ matrix.arch }}
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-${{ matrix.arch }}
labels: |
coolify.managed=true
merge-manifest:
runs-on: ubuntu-24.04
needs: build-push
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- uses: docker/setup-buildx-action@v3
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.GITHUB_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to ${{ env.DOCKER_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Get Version
id: version
run: |
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getHelperVersion.php)"|xargs >> $GITHUB_OUTPUT
- name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }}
run: |
docker buildx imagetools create \
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-amd64 \
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-aarch64 \
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next \
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:next
- name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }}
run: |
docker buildx imagetools create \
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-amd64 \
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-aarch64 \
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next \
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:next
- uses: sarisia/actions-status-discord@v1
if: always()
with:
webhook: ${{ secrets.DISCORD_WEBHOOK_DEV_RELEASE_CHANNEL }}

View file

@ -1,161 +0,0 @@
name: Coolify Helper Image
on:
workflow_dispatch:
push:
branches: [ "main" ]
paths:
- .github/workflows/coolify-helper.yml
- docker/coolify-helper/Dockerfile
permissions:
contents: read
packages: write
env:
GITHUB_REGISTRY: ghcr.io
DOCKER_REGISTRY: docker.io
IMAGE_NAME: "coollabsio/coolify-helper"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
jobs:
check-version:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- uses: docker/setup-buildx-action@v3
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.GITHUB_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to ${{ env.DOCKER_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Ensure version is not published
run: |
BASE_VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getHelperVersion.php)
VERSION="${BASE_VERSION}"
for registry in "${DOCKER_REGISTRY}" "${GITHUB_REGISTRY}"; do
IMAGE="${registry}/${IMAGE_NAME}:${VERSION}"
if output=$(docker buildx imagetools inspect "$IMAGE" 2>&1); then
echo "::error::Version $VERSION already exists in $registry"
exit 1
fi
if ! grep -Eqi 'manifest unknown|not found|no such manifest' <<< "$output"; then
echo "::error::Could not verify $IMAGE: $output"
exit 1
fi
done
echo "Version $VERSION is available in both registries"
build-push:
needs: check-version
strategy:
matrix:
include:
- arch: amd64
platform: linux/amd64
runner: ubuntu-24.04
- arch: aarch64
platform: linux/aarch64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.GITHUB_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to ${{ env.DOCKER_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Get Version
id: version
run: |
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getHelperVersion.php)"|xargs >> $GITHUB_OUTPUT
- name: Build and Push Image (${{ matrix.arch }})
uses: docker/build-push-action@v6
with:
context: .
file: docker/coolify-helper/Dockerfile
platforms: ${{ matrix.platform }}
push: true
tags: |
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-${{ matrix.arch }}
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-${{ matrix.arch }}
labels: |
coolify.managed=true
merge-manifest:
runs-on: ubuntu-24.04
needs: build-push
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- uses: docker/setup-buildx-action@v3
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.GITHUB_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to ${{ env.DOCKER_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Get Version
id: version
run: |
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getHelperVersion.php)"|xargs >> $GITHUB_OUTPUT
- name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }}
run: |
docker buildx imagetools create \
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-amd64 \
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 \
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }} \
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
- name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }}
run: |
docker buildx imagetools create \
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-amd64 \
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 \
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }} \
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
- uses: sarisia/actions-status-discord@v1
if: always()
with:
webhook: ${{ secrets.DISCORD_WEBHOOK_PROD_RELEASE_CHANNEL }}

View file

@ -1,152 +0,0 @@
name: Build Coolify Next
on:
push:
branches: [next]
paths-ignore:
- .github/workflows/coolify-helper.yml
- .github/workflows/coolify-helper-next.yml
- .github/workflows/coolify-realtime.yml
- .github/workflows/coolify-realtime-next.yml
- .github/workflows/pr-quality.yaml
- docker/coolify-helper/Dockerfile
- docker/coolify-realtime/Dockerfile
- docker/testing-host/Dockerfile
- templates/**
- CHANGELOG.md
permissions:
contents: read
packages: write
concurrency:
group: coolify-next-build
cancel-in-progress: false
env:
GITHUB_REGISTRY: ghcr.io
DOCKER_REGISTRY: docker.io
IMAGE_NAME: coollabsio/coolify
jobs:
prepare:
runs-on: ubuntu-24.04
outputs:
rc_version: ${{ steps.version.outputs.rc_version }}
short_sha: ${{ steps.version.outputs.short_sha }}
version: ${{ steps.version.outputs.version }}
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- name: Resolve next version
id: version
run: |
RC_VERSION=$(jq -r '.coolify.nightly.version' versions.json)
if [[ ! "${RC_VERSION}" =~ ^[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then
echo "Invalid next RC version: ${RC_VERSION}"
exit 1
fi
SHORT_SHA="${GITHUB_SHA::7}"
VERSION="${RC_VERSION}.${SHORT_SHA}"
echo "rc_version=${RC_VERSION}" >> "$GITHUB_OUTPUT"
echo "short_sha=${SHORT_SHA}" >> "$GITHUB_OUTPUT"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
build:
needs: prepare
strategy:
matrix:
include:
- arch: amd64
platform: linux/amd64
runner: ubuntu-24.04
- arch: aarch64
platform: linux/aarch64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- uses: docker/setup-buildx-action@v3
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.GITHUB_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to ${{ env.DOCKER_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push next image (${{ matrix.arch }})
uses: docker/build-push-action@v6
with:
context: .
file: docker/production/Dockerfile
platforms: ${{ matrix.platform }}
push: true
build-args: |
COOLIFY_VERSION=${{ needs.prepare.outputs.version }}
tags: |
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:next-build-${{ needs.prepare.outputs.short_sha }}-${{ matrix.arch }}
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:next-build-${{ needs.prepare.outputs.short_sha }}-${{ matrix.arch }}
publish:
needs: [prepare, build]
runs-on: ubuntu-24.04
steps:
- uses: docker/setup-buildx-action@v3
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.GITHUB_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to ${{ env.DOCKER_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Publish next manifest on ${{ env.GITHUB_REGISTRY }}
env:
REGISTRY: ${{ env.GITHUB_REGISTRY }}
SHA: ${{ needs.prepare.outputs.short_sha }}
VERSION: ${{ needs.prepare.outputs.version }}
run: |
IMAGE="${REGISTRY}/${IMAGE_NAME}"
SOURCE="next-build-${SHA}"
docker buildx imagetools create \
"${IMAGE}:${SOURCE}-amd64" \
"${IMAGE}:${SOURCE}-aarch64" \
--tag "${IMAGE}:sha-${SHA}" \
--tag "${IMAGE}:${VERSION}" \
--tag "${IMAGE}:next"
- name: Publish next manifest on ${{ env.DOCKER_REGISTRY }}
env:
REGISTRY: ${{ env.DOCKER_REGISTRY }}
SHA: ${{ needs.prepare.outputs.short_sha }}
VERSION: ${{ needs.prepare.outputs.version }}
run: |
IMAGE="${REGISTRY}/${IMAGE_NAME}"
SOURCE="next-build-${SHA}"
docker buildx imagetools create \
"${IMAGE}:${SOURCE}-amd64" \
"${IMAGE}:${SOURCE}-aarch64" \
--tag "${IMAGE}:sha-${SHA}" \
--tag "${IMAGE}:${VERSION}" \
--tag "${IMAGE}:next"

View file

@ -1,304 +0,0 @@
name: Release Coolify RC
run-name: ${{ inputs.tag }}
on:
workflow_dispatch:
inputs:
tag:
description: Existing draft prerelease tag (for example, v4.4-rc.1)
required: true
type: string
permissions: {}
concurrency:
group: coolify-rc-release
cancel-in-progress: false
env:
GITHUB_REGISTRY: ghcr.io
DOCKER_REGISTRY: docker.io
IMAGE_NAME: coollabsio/coolify
jobs:
validate:
runs-on: ubuntu-24.04
permissions:
contents: write
outputs:
release_id: ${{ steps.draft.outputs.release_id }}
version: ${{ steps.version.outputs.version }}
steps:
- name: Reject releases outside next
if: ${{ github.ref != 'refs/heads/next' }}
run: |
echo "RC releases must run from the next branch, not ${{ github.ref }}."
exit 1
- uses: actions/checkout@v5
with:
fetch-depth: 0
persist-credentials: false
- name: Validate version
id: version
env:
TAG_NAME: ${{ inputs.tag }}
run: |
if [[ ! "${TAG_NAME}" =~ ^v[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then
echo "Unsupported RC tag: ${TAG_NAME}"
exit 1
fi
VERSION="${TAG_NAME#v}"
CONFIG_VERSION=$(jq -r '.coolify.nightly.version' versions.json)
if [[ "${CONFIG_VERSION}" != "${VERSION}" ]]; then
echo "RC tag ${VERSION} does not match nightly version ${CONFIG_VERSION}."
exit 1
fi
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
- name: Validate and pin draft prerelease
id: draft
uses: actions/github-script@v8
env:
TAG_NAME: ${{ inputs.tag }}
with:
script: |
const releases = await github.paginate(github.rest.repos.listReleases, {
owner: context.repo.owner,
repo: context.repo.repo,
per_page: 100,
});
const release = releases.find((candidate) => candidate.tag_name === process.env.TAG_NAME);
if (!release) {
core.setFailed(`Create a draft prerelease for ${process.env.TAG_NAME} before running this workflow.`);
return;
}
if (!release.draft) {
core.setFailed(`Release ${process.env.TAG_NAME} must still be a draft.`);
return;
}
if (!release.prerelease) {
core.setFailed(`RC release ${process.env.TAG_NAME} must be marked as a prerelease.`);
return;
}
if (!release.body?.trim()) {
core.setFailed(`Draft prerelease ${process.env.TAG_NAME} must contain reviewed release notes.`);
return;
}
try {
await github.rest.git.getRef({
owner: context.repo.owner,
repo: context.repo.repo,
ref: `tags/${process.env.TAG_NAME}`,
});
core.setFailed(`Git tag ${process.env.TAG_NAME} already exists.`);
return;
} catch (error) {
if (error.status !== 404) throw error;
}
await github.rest.repos.updateRelease({
owner: context.repo.owner,
repo: context.repo.repo,
release_id: release.id,
tag_name: process.env.TAG_NAME,
target_commitish: context.sha,
prerelease: true,
});
core.setOutput('release_id', release.id);
build:
needs: validate
permissions:
contents: read
packages: write
strategy:
matrix:
include:
- arch: amd64
platform: linux/amd64
runner: ubuntu-24.04
- arch: aarch64
platform: linux/aarch64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- uses: docker/setup-buildx-action@v3
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.GITHUB_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to ${{ env.DOCKER_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push RC image (${{ matrix.arch }})
uses: docker/build-push-action@v6
with:
context: .
file: docker/production/Dockerfile
platforms: ${{ matrix.platform }}
push: true
build-args: |
COOLIFY_VERSION=${{ needs.validate.outputs.version }}
tags: |
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:rc-release-${{ needs.validate.outputs.version }}-${{ github.sha }}-${{ matrix.arch }}
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:rc-release-${{ needs.validate.outputs.version }}-${{ github.sha }}-${{ matrix.arch }}
revalidate:
needs: [validate, build]
runs-on: ubuntu-24.04
permissions:
contents: write
steps:
- name: Revalidate draft prerelease
uses: actions/github-script@v8
env:
RELEASE_ID: ${{ needs.validate.outputs.release_id }}
TAG_NAME: ${{ inputs.tag }}
with:
script: |
const releaseId = Number(process.env.RELEASE_ID);
const { data: release } = await github.rest.repos.getRelease({
owner: context.repo.owner,
repo: context.repo.repo,
release_id: releaseId,
});
if (release.tag_name !== process.env.TAG_NAME || !release.draft || !release.prerelease) {
core.setFailed(`Draft prerelease ${process.env.TAG_NAME} changed while the images were building.`);
return;
}
if (!release.body?.trim()) {
core.setFailed(`Draft prerelease ${process.env.TAG_NAME} no longer contains release notes.`);
return;
}
if (release.target_commitish !== context.sha) {
core.setFailed(`Draft prerelease ${process.env.TAG_NAME} no longer targets ${context.sha}.`);
return;
}
try {
await github.rest.git.getRef({
owner: context.repo.owner,
repo: context.repo.repo,
ref: `tags/${process.env.TAG_NAME}`,
});
core.setFailed(`Git tag ${process.env.TAG_NAME} was created while the images were building.`);
} catch (error) {
if (error.status !== 404) throw error;
}
publish:
needs: [validate, build, revalidate]
runs-on: ubuntu-24.04
permissions:
contents: write
packages: write
steps:
- uses: docker/setup-buildx-action@v3
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.GITHUB_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to ${{ env.DOCKER_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Publish RC and next on ${{ env.GITHUB_REGISTRY }}
env:
REGISTRY: ${{ env.GITHUB_REGISTRY }}
VERSION: ${{ needs.validate.outputs.version }}
run: |
IMAGE="${REGISTRY}/${IMAGE_NAME}"
SOURCE="rc-release-${VERSION}-${GITHUB_SHA}"
docker buildx imagetools create \
"${IMAGE}:${SOURCE}-amd64" \
"${IMAGE}:${SOURCE}-aarch64" \
--tag "${IMAGE}:${VERSION}" \
--tag "${IMAGE}:next"
- name: Publish RC and next on ${{ env.DOCKER_REGISTRY }}
env:
REGISTRY: ${{ env.DOCKER_REGISTRY }}
VERSION: ${{ needs.validate.outputs.version }}
run: |
IMAGE="${REGISTRY}/${IMAGE_NAME}"
SOURCE="rc-release-${VERSION}-${GITHUB_SHA}"
docker buildx imagetools create \
"${IMAGE}:${SOURCE}-amd64" \
"${IMAGE}:${SOURCE}-aarch64" \
--tag "${IMAGE}:${VERSION}" \
--tag "${IMAGE}:next"
- name: Publish reviewed draft prerelease
uses: actions/github-script@v8
env:
RELEASE_ID: ${{ needs.validate.outputs.release_id }}
TAG_NAME: ${{ inputs.tag }}
with:
script: |
const releaseId = Number(process.env.RELEASE_ID);
const { data: release } = await github.rest.repos.getRelease({
owner: context.repo.owner,
repo: context.repo.repo,
release_id: releaseId,
});
if (release.tag_name !== process.env.TAG_NAME || !release.draft || !release.prerelease) {
core.setFailed(`Draft prerelease ${process.env.TAG_NAME} changed while the images were building.`);
return;
}
if (!release.body?.trim()) {
core.setFailed(`Draft prerelease ${process.env.TAG_NAME} no longer contains release notes.`);
return;
}
if (release.target_commitish !== context.sha) {
core.setFailed(`Draft prerelease ${process.env.TAG_NAME} no longer targets ${context.sha}.`);
return;
}
try {
await github.rest.git.getRef({
owner: context.repo.owner,
repo: context.repo.repo,
ref: `tags/${process.env.TAG_NAME}`,
});
core.setFailed(`Git tag ${process.env.TAG_NAME} was created while the images were building.`);
return;
} catch (error) {
if (error.status !== 404) throw error;
}
await github.rest.repos.updateRelease({
owner: context.repo.owner,
repo: context.repo.repo,
release_id: Number(process.env.RELEASE_ID),
tag_name: process.env.TAG_NAME,
target_commitish: context.sha,
prerelease: true,
draft: false,
});

View file

@ -1,120 +0,0 @@
name: Coolify Realtime Development
on:
push:
branches: [ "next" ]
paths:
- .github/workflows/coolify-realtime-next.yml
- docker/coolify-realtime/Dockerfile
- docker/coolify-realtime/terminal-server.js
- docker/coolify-realtime/package.json
- docker/coolify-realtime/package-lock.json
- docker/coolify-realtime/soketi-entrypoint.sh
permissions:
contents: read
packages: write
env:
GITHUB_REGISTRY: ghcr.io
DOCKER_REGISTRY: docker.io
IMAGE_NAME: "coollabsio/coolify-realtime"
jobs:
build-push:
strategy:
matrix:
include:
- arch: amd64
platform: linux/amd64
runner: ubuntu-24.04
- arch: aarch64
platform: linux/aarch64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.GITHUB_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to ${{ env.DOCKER_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Get Version
id: version
run: |
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getRealtimeVersion.php)"|xargs >> $GITHUB_OUTPUT
- name: Build and Push Image (${{ matrix.arch }})
uses: docker/build-push-action@v6
with:
context: .
file: docker/coolify-realtime/Dockerfile
platforms: ${{ matrix.platform }}
push: true
tags: |
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-${{ matrix.arch }}
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-${{ matrix.arch }}
labels: |
coolify.managed=true
merge-manifest:
runs-on: ubuntu-24.04
needs: build-push
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- uses: docker/setup-buildx-action@v3
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.GITHUB_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to ${{ env.DOCKER_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Get Version
id: version
run: |
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getRealtimeVersion.php)"|xargs >> $GITHUB_OUTPUT
- name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }}
run: |
docker buildx imagetools create \
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-amd64 \
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-aarch64 \
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next \
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:next
- name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }}
run: |
docker buildx imagetools create \
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-amd64 \
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-aarch64 \
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next \
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:next
- uses: sarisia/actions-status-discord@v1
if: always()
with:
webhook: ${{ secrets.DISCORD_WEBHOOK_DEV_RELEASE_CHANNEL }}

View file

@ -1,161 +0,0 @@
name: Coolify Realtime
on:
push:
branches: [ "main" ]
paths:
- .github/workflows/coolify-realtime.yml
- docker/coolify-realtime/**
permissions:
contents: read
packages: write
env:
GITHUB_REGISTRY: ghcr.io
DOCKER_REGISTRY: docker.io
IMAGE_NAME: "coollabsio/coolify-realtime"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
jobs:
check-version:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- uses: docker/setup-buildx-action@v3
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.GITHUB_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to ${{ env.DOCKER_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Ensure version is not published
run: |
BASE_VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getRealtimeVersion.php)
VERSION="${BASE_VERSION}"
for registry in "${DOCKER_REGISTRY}" "${GITHUB_REGISTRY}"; do
IMAGE="${registry}/${IMAGE_NAME}:${VERSION}"
if output=$(docker buildx imagetools inspect "$IMAGE" 2>&1); then
echo "::error::Version $VERSION already exists in $registry"
exit 1
fi
if ! grep -Eqi 'manifest unknown|not found|no such manifest' <<< "$output"; then
echo "::error::Could not verify $IMAGE: $output"
exit 1
fi
done
echo "Version $VERSION is available in both registries"
build-push:
needs: check-version
strategy:
matrix:
include:
- arch: amd64
platform: linux/amd64
runner: ubuntu-24.04
- arch: aarch64
platform: linux/aarch64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.GITHUB_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to ${{ env.DOCKER_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Get Version
id: version
run: |
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getRealtimeVersion.php)"|xargs >> $GITHUB_OUTPUT
- name: Build and Push Image (${{ matrix.arch }})
uses: docker/build-push-action@v6
with:
context: .
file: docker/coolify-realtime/Dockerfile
platforms: ${{ matrix.platform }}
push: true
tags: |
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-${{ matrix.arch }}
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-${{ matrix.arch }}
labels: |
coolify.managed=true
merge-manifest:
runs-on: ubuntu-24.04
needs: build-push
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- uses: docker/setup-buildx-action@v3
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.GITHUB_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to ${{ env.DOCKER_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Get Version
id: version
run: |
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getRealtimeVersion.php)"|xargs >> $GITHUB_OUTPUT
- name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }}
run: |
docker buildx imagetools create \
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-amd64 \
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 \
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }} \
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
- name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }}
run: |
docker buildx imagetools create \
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-amd64 \
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 \
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }} \
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
- uses: sarisia/actions-status-discord@v1
if: always()
with:
webhook: ${{ secrets.DISCORD_WEBHOOK_PROD_RELEASE_CHANNEL }}

View file

@ -1,259 +0,0 @@
name: Release Coolify Stable
run-name: ${{ inputs.tag }}
on:
workflow_dispatch:
inputs:
tag:
description: Existing draft release tag (for example, v4.3.1)
required: true
type: string
permissions: {}
concurrency:
group: coolify-fix-release
cancel-in-progress: false
env:
GITHUB_REGISTRY: ghcr.io
DOCKER_REGISTRY: docker.io
IMAGE_NAME: coollabsio/coolify
jobs:
validate:
runs-on: ubuntu-24.04
permissions:
contents: write
outputs:
release_id: ${{ steps.draft.outputs.release_id }}
version: ${{ steps.version.outputs.version }}
steps:
- name: Reject releases outside the production branch
if: ${{ github.ref_name != 'main' }}
run: |
echo "Stable releases must run from main, not ${{ github.ref_name }}."
exit 1
- uses: actions/checkout@v5
with:
fetch-depth: 0
persist-credentials: false
- name: Validate version
id: version
env:
TAG_NAME: ${{ inputs.tag }}
run: |
if [[ ! "${TAG_NAME}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Unsupported fix release tag: ${TAG_NAME}"
exit 1
fi
VERSION="${TAG_NAME#v}"
CONFIG_VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getVersion.php)
if [[ "${CONFIG_VERSION}" != "${VERSION}" ]]; then
echo "Release tag ${VERSION} does not match config version ${CONFIG_VERSION}."
exit 1
fi
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
- name: Validate and pin draft release
id: draft
uses: actions/github-script@v8
env:
TAG_NAME: ${{ inputs.tag }}
with:
script: |
const releases = await github.paginate(github.rest.repos.listReleases, {
owner: context.repo.owner,
repo: context.repo.repo,
per_page: 100,
});
const release = releases.find((candidate) => candidate.tag_name === process.env.TAG_NAME);
if (!release) {
core.setFailed(`Create a draft release for ${process.env.TAG_NAME} before running this workflow.`);
return;
}
if (!release.draft) {
core.setFailed(`Release ${process.env.TAG_NAME} must still be a draft.`);
return;
}
if (release.prerelease) {
core.setFailed(`Fix release ${process.env.TAG_NAME} cannot be marked as a prerelease.`);
return;
}
if (!release.body?.trim()) {
core.setFailed(`Draft release ${process.env.TAG_NAME} must contain reviewed release notes.`);
return;
}
try {
await github.rest.git.getRef({
owner: context.repo.owner,
repo: context.repo.repo,
ref: `tags/${process.env.TAG_NAME}`,
});
core.setFailed(`Git tag ${process.env.TAG_NAME} already exists.`);
return;
} catch (error) {
if (error.status !== 404) throw error;
}
await github.rest.repos.updateRelease({
owner: context.repo.owner,
repo: context.repo.repo,
release_id: release.id,
tag_name: process.env.TAG_NAME,
target_commitish: context.sha,
});
core.setOutput('release_id', release.id);
build:
needs: validate
permissions:
contents: read
packages: write
strategy:
matrix:
include:
- arch: amd64
platform: linux/amd64
runner: ubuntu-24.04
- arch: aarch64
platform: linux/aarch64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- uses: docker/setup-buildx-action@v3
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.GITHUB_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to ${{ env.DOCKER_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push release image (${{ matrix.arch }})
uses: docker/build-push-action@v6
with:
context: .
file: docker/production/Dockerfile
platforms: ${{ matrix.platform }}
push: true
build-args: |
COOLIFY_VERSION=${{ needs.validate.outputs.version }}
tags: |
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:release-${{ needs.validate.outputs.version }}-${{ github.sha }}-${{ matrix.arch }}
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:release-${{ needs.validate.outputs.version }}-${{ github.sha }}-${{ matrix.arch }}
publish:
needs: [validate, build]
runs-on: ubuntu-24.04
permissions:
contents: write
packages: write
steps:
- uses: docker/setup-buildx-action@v3
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.GITHUB_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to ${{ env.DOCKER_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Publish version and latest on ${{ env.GITHUB_REGISTRY }}
env:
REGISTRY: ${{ env.GITHUB_REGISTRY }}
VERSION: ${{ needs.validate.outputs.version }}
run: |
IMAGE="${REGISTRY}/${IMAGE_NAME}"
SOURCE="release-${VERSION}-${GITHUB_SHA}"
docker buildx imagetools create \
"${IMAGE}:${SOURCE}-amd64" \
"${IMAGE}:${SOURCE}-aarch64" \
--tag "${IMAGE}:${VERSION}" \
--tag "${IMAGE}:latest"
- name: Publish version and latest on ${{ env.DOCKER_REGISTRY }}
env:
REGISTRY: ${{ env.DOCKER_REGISTRY }}
VERSION: ${{ needs.validate.outputs.version }}
run: |
IMAGE="${REGISTRY}/${IMAGE_NAME}"
SOURCE="release-${VERSION}-${GITHUB_SHA}"
docker buildx imagetools create \
"${IMAGE}:${SOURCE}-amd64" \
"${IMAGE}:${SOURCE}-aarch64" \
--tag "${IMAGE}:${VERSION}" \
--tag "${IMAGE}:latest"
- name: Publish reviewed draft release
uses: actions/github-script@v8
env:
RELEASE_ID: ${{ needs.validate.outputs.release_id }}
TAG_NAME: ${{ inputs.tag }}
with:
script: |
const releaseId = Number(process.env.RELEASE_ID);
const { data: release } = await github.rest.repos.getRelease({
owner: context.repo.owner,
repo: context.repo.repo,
release_id: releaseId,
});
if (release.tag_name !== process.env.TAG_NAME || !release.draft || release.prerelease) {
core.setFailed(`Draft release ${process.env.TAG_NAME} changed while the images were building.`);
return;
}
if (!release.body?.trim()) {
core.setFailed(`Draft release ${process.env.TAG_NAME} no longer contains release notes.`);
return;
}
if (release.target_commitish !== context.sha) {
core.setFailed(`Draft release ${process.env.TAG_NAME} no longer targets ${context.sha}.`);
return;
}
try {
await github.rest.git.getRef({
owner: context.repo.owner,
repo: context.repo.repo,
ref: `tags/${process.env.TAG_NAME}`,
});
core.setFailed(`Git tag ${process.env.TAG_NAME} was created while the images were building.`);
return;
} catch (error) {
if (error.status !== 404) throw error;
}
await github.rest.repos.updateRelease({
owner: context.repo.owner,
repo: context.repo.repo,
release_id: Number(process.env.RELEASE_ID),
tag_name: process.env.TAG_NAME,
target_commitish: context.sha,
draft: false,
});

View file

@ -1,109 +0,0 @@
name: Build Coolify (SHA)
on:
push:
branches: ["main"]
permissions:
contents: read
packages: write
env:
GITHUB_REGISTRY: ghcr.io
DOCKER_REGISTRY: docker.io
IMAGE_NAME: "coollabsio/coolify"
jobs:
build-push:
outputs:
short_sha: ${{ steps.version.outputs.short_sha }}
strategy:
matrix:
include:
- arch: amd64
platform: linux/amd64
runner: ubuntu-24.04
- arch: aarch64
platform: linux/aarch64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- name: Resolve internal version
id: version
run: |
BASE_VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getVersion.php)
echo "version=${BASE_VERSION}-dev.${GITHUB_SHA::9}" >> "$GITHUB_OUTPUT"
echo "short_sha=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.GITHUB_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to ${{ env.DOCKER_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and Push Image (${{ matrix.arch }})
uses: docker/build-push-action@v6
with:
context: .
file: docker/production/Dockerfile
platforms: ${{ matrix.platform }}
push: true
build-args: |
COOLIFY_VERSION=${{ steps.version.outputs.version }}
tags: |
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ steps.version.outputs.short_sha }}-${{ matrix.arch }}
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ steps.version.outputs.short_sha }}-${{ matrix.arch }}
merge-manifest:
runs-on: ubuntu-24.04
needs: build-push
steps:
- uses: docker/setup-buildx-action@v3
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.GITHUB_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to ${{ env.DOCKER_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }}
env:
REGISTRY: ${{ env.GITHUB_REGISTRY }}
SHA: ${{ needs.build-push.outputs.short_sha }}
run: |
IMAGE="${REGISTRY}/${IMAGE_NAME}"
docker buildx imagetools create \
"${IMAGE}:sha-${SHA}-amd64" \
"${IMAGE}:sha-${SHA}-aarch64" \
--tag "${IMAGE}:sha-${SHA}"
- name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }}
env:
REGISTRY: ${{ env.DOCKER_REGISTRY }}
SHA: ${{ needs.build-push.outputs.short_sha }}
run: |
IMAGE="${REGISTRY}/${IMAGE_NAME}"
docker buildx imagetools create \
"${IMAGE}:sha-${SHA}-amd64" \
"${IMAGE}:sha-${SHA}-aarch64" \
--tag "${IMAGE}:sha-${SHA}"

View file

@ -1,104 +0,0 @@
name: Coolify Testing Host
on:
push:
branches: [ "next" ]
paths:
- .github/workflows/coolify-testing-host.yml
- docker/testing-host/Dockerfile
permissions:
contents: read
packages: write
env:
GITHUB_REGISTRY: ghcr.io
DOCKER_REGISTRY: docker.io
IMAGE_NAME: "coollabsio/coolify-testing-host"
jobs:
build-push:
strategy:
matrix:
include:
- arch: amd64
platform: linux/amd64
runner: ubuntu-24.04
- arch: aarch64
platform: linux/aarch64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.GITHUB_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to ${{ env.DOCKER_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and Push Image (${{ matrix.arch }})
uses: docker/build-push-action@v6
with:
context: .
file: docker/testing-host/Dockerfile
platforms: ${{ matrix.platform }}
push: true
tags: |
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest-${{ matrix.arch }}
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest-${{ matrix.arch }}
labels: |
coolify.managed=true
merge-manifest:
runs-on: ubuntu-24.04
needs: build-push
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- uses: docker/setup-buildx-action@v3
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.GITHUB_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to ${{ env.DOCKER_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }}
run: |
docker buildx imagetools create \
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest-amd64 \
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest-aarch64 \
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
- name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }}
run: |
docker buildx imagetools create \
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest-amd64 \
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest-aarch64 \
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
- uses: sarisia/actions-status-discord@v1
if: always()
with:
webhook: ${{ secrets.DISCORD_WEBHOOK_DEV_RELEASE_CHANNEL }}

View file

@ -1,42 +0,0 @@
name: Generate Changelog
on:
push:
branches: [ main ]
paths-ignore:
- .github/workflows/coolify-helper.yml
- .github/workflows/coolify-helper-next.yml
- .github/workflows/coolify-realtime.yml
- .github/workflows/coolify-realtime-next.yml
- .github/workflows/pr-quality.yaml
workflow_dispatch:
permissions:
contents: write
jobs:
changelog:
name: Generate changelog
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Generate changelog
uses: orhun/git-cliff-action@v4
with:
config: cliff.toml
args: --verbose
env:
OUTPUT: CHANGELOG.md
GITHUB_REPO: ${{ github.repository }}
- name: Commit
run: |
git config user.name 'github-actions[bot]'
git config user.email 'github-actions[bot]@users.noreply.github.com'
git add CHANGELOG.md
git commit -m "docs: update changelog"
git push https://${{ secrets.GITHUB_TOKEN }}@github.com/${GITHUB_REPOSITORY}.git HEAD:${GITHUB_REF_NAME}

View file

@ -1,108 +0,0 @@
name: PR Quality
permissions:
contents: read
issues: read
pull-requests: write
on:
pull_request_target:
types: [opened, reopened]
jobs:
pr-quality:
runs-on: ubuntu-latest
steps:
- uses: peakoss/anti-slop@v0
with:
# General Settings
max-failures: 4
# PR Branch Checks
allowed-target-branches: ""
blocked-target-branches: ""
allowed-source-branches: ""
blocked-source-branches: ""
# PR Quality Checks
max-negative-reactions: 0
require-maintainer-can-modify: true
# PR Title Checks
require-conventional-title: true
# PR Description Checks
require-description: true
max-description-length: 2500
max-emoji-count: 2
max-code-references: 5
require-linked-issue: false
blocked-terms: |
STRAWBERRY
🤖 Generated with Claude Code
Generated with Claude Code
blocked-issue-numbers: 8154
# PR Template Checks
require-pr-template: true
strict-pr-template-sections: "Contributor Agreement"
optional-pr-template-sections: "Issues,Preview"
max-additional-pr-template-sections: 2
# Commit Message Checks
max-commit-message-length: 500
require-conventional-commits: false
require-commit-author-match: true
blocked-commit-authors: ""
# File Checks
allowed-file-extensions: ""
allowed-paths: ""
blocked-paths: |
README.md
SECURITY.md
LICENSE
CODE_OF_CONDUCT.md
templates/service-templates-latest.json
templates/service-templates.json
require-final-newline: true
max-added-comments: 10
# User Checks
detect-spam-usernames: true
min-account-age: 30
max-daily-forks: 7
min-profile-completeness: 4
# Merge Checks
min-repo-merged-prs: 0
min-repo-merge-ratio: 0
min-global-merge-ratio: 30
global-merge-ratio-exclude-own: false
# Exemptions
exempt-draft-prs: false
exempt-bots: |
actions-user
dependabot[bot]
renovate[bot]
github-actions[bot]
exempt-users: ""
exempt-author-association: "OWNER,MEMBER,COLLABORATOR"
exempt-label: "quality/exempt"
exempt-pr-label: ""
exempt-all-milestones: false
exempt-all-pr-milestones: false
exempt-milestones: ""
exempt-pr-milestones: ""
# PR Success Actions
success-add-pr-labels: ""
# PR Failure Actions
failure-remove-pr-labels: ""
failure-remove-all-pr-labels: true
failure-add-pr-labels: "quality/rejected"
failure-pr-message: "This PR did not pass quality checks so it will be closed. If you believe this is a mistake please let us know."
close-pr: true
lock-pr: false

View file

@ -1,62 +0,0 @@
name: Sync main to next
on:
schedule:
- cron: '0 3 * * *'
workflow_dispatch:
permissions:
contents: write
pull-requests: write
concurrency:
group: sync-main-to-next
cancel-in-progress: false
jobs:
sync:
name: Merge main into next
runs-on: ubuntu-latest
steps:
- name: Checkout next
uses: actions/checkout@v5
with:
ref: next
fetch-depth: 0
- name: Merge main into next
env:
GH_TOKEN: ${{ github.token }}
run: |
git config user.name 'github-actions[bot]'
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
git fetch origin main next
if git merge --no-edit origin/main; then
git push origin HEAD:next
exit 0
fi
conflicts=$(git diff --name-only --diff-filter=U)
git merge --abort
if [ -z "$conflicts" ]; then
echo 'The merge failed without conflicts, so no pull request was created.'
exit 1
fi
sync_branch='automation/sync-main-to-next'
existing_pr=$(gh pr list --base next --head "$sync_branch" --state open --json url --jq '.[0].url')
if [ -n "$existing_pr" ]; then
echo "A main to next pull request already exists: $existing_pr"
else
git push --force origin origin/main:"refs/heads/$sync_branch"
gh pr create \
--base next \
--head "$sync_branch" \
--title 'chore: merge main into next' \
--body 'This pull request was created automatically because main could not be merged into next without conflicts. Resolve conflicts on this temporary branch; never update main with next.'
fi
echo 'main could not be merged into next without conflicts.'
exit 1

View file

@ -6,6 +6,7 @@
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rules\Password;
use Illuminate\Validation\ValidationException;
use Laravel\Fortify\Contracts\ResetsUserPasswords;
class ResetUserPassword implements ResetsUserPasswords
@ -17,6 +18,13 @@ class ResetUserPassword implements ResetsUserPasswords
*/
public function reset(User $user, array $input): void
{
if ($user->isMapledeployRevoked()) {
// MapleDeploy branding: dashboard-managed revocation is restored only by mapledeploy:user:set-password.
throw ValidationException::withMessages([
'email' => [trans('passwords.user')],
]);
}
Validator::make($input, [
'password' => ['required', Password::defaults(), 'confirmed'],
])->validate();

View file

@ -26,7 +26,8 @@ public function handle(Server $server, bool $restart = false, ?string $latestVer
$endpoint = $server->settings->ensureSentinelUrl();
$debug = data_get($server, 'settings.is_sentinel_debug_enabled');
$mountDir = '/data/coolify/sentinel';
$image = coolifyRegistryUrl().'/coollabsio/sentinel:'.$version;
// MapleDeploy branding: Sentinel is not mirrored to our Forgejo registry, so pull from ghcr.io directly (upstream image)
$image = 'ghcr.io/coollabsio/sentinel:'.$version;
$environments = [
'TOKEN' => $token,
'DEBUG' => $debug ? 'true' : 'false',

View file

@ -118,7 +118,8 @@ private function update()
{
$latestHelperImageVersion = getHelperVersion();
$upgradeScriptUrl = config('constants.coolify.upgrade_script_url');
$registryUrl = coolifyRegistryUrl();
// MapleDeploy branding: always use the fork registry default, ignoring per-instance overrides
$registryUrl = config('constants.coolify.registry_url');
remote_process([
"curl -fsSL {$upgradeScriptUrl} -o /data/coolify/source/upgrade.sh",

View file

@ -265,15 +265,11 @@ private function restoreCoolifyDbBackup()
}
}
// MapleDeploy branding: telemetry disabled — no phone-home signal
private function sendAliveSignal()
{
$id = config('app.id');
$version = config('constants.coolify.version');
try {
Http::get("https://undead.coolify.io/v4/alive?appId=$id&version=$version");
} catch (\Throwable $e) {
echo "Error in sending live signal: {$e->getMessage()}\n";
}
// Disabled for MapleDeploy: do not send telemetry to coolify.io
return;
}
private function replaceSlashInEnvironmentName()

View file

@ -0,0 +1,160 @@
<?php
namespace App\Console\Commands\Mapledeploy;
use App\Enums\Role;
use App\Models\Team;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
class UserCreate extends Command
{
protected $signature = 'mapledeploy:user:create
{--email= : User email address}
{--name= : User display name}
{--admin : Create the first root admin user}
{--team-role=member : Root team role for non-admin users}';
protected $description = 'Create a Coolify user for MapleDeploy dashboard access management';
public function handle(): int
{
$password = $this->readPassword();
$input = [
'email' => $this->option('email'),
'name' => $this->option('name'),
'password' => $password,
'team_role' => $this->option('team-role'),
];
$validator = Validator::make($input, [
'email' => ['required', 'string', 'email', 'max:255'],
'name' => ['required', 'string', 'max:255'],
'password' => ['required', 'string', 'min:8'],
'team_role' => ['required', Rule::in([Role::ADMIN->value, Role::MEMBER->value])],
]);
if ($validator->fails()) {
return $this->failWith('INVALID_INPUT');
}
$input['email'] = Str::lower((string) $input['email']);
if (User::whereEmail($input['email'])->exists()) {
return $this->failWith('EMAIL_EXISTS');
}
if ($this->option('admin')) {
return $this->createAdmin($input);
}
return $this->createMember($input);
}
private function createAdmin(array $input): int
{
if (User::count() !== 0) {
return $this->failWith('USERS_ALREADY_EXIST');
}
$user = DB::transaction(function () use ($input) {
$user = (new User)->forceFill([
'id' => 0,
'name' => $input['name'],
'email' => $input['email'],
'password' => Hash::make($input['password']),
]);
$user->save();
$user->markEmailAsVerified();
$settings = instanceSettings();
$settings->is_registration_enabled = false;
$attributes = $settings->getAttributes();
if (array_key_exists('setup_token', $attributes)) {
$settings->setup_token = null;
}
if (array_key_exists('setup_callback_url', $attributes)) {
$settings->setup_callback_url = null;
}
$settings->save();
return $user;
});
return $this->succeedWithUser($user);
}
private function createMember(array $input): int
{
$rootTeam = Team::find(0);
if (! $rootTeam) {
return $this->failWith('ROOT_TEAM_MISSING');
}
$user = DB::transaction(function () use ($input, $rootTeam) {
$user = User::create([
'name' => $input['name'],
'email' => $input['email'],
'password' => Hash::make($input['password']),
]);
$user->markEmailAsVerified();
$this->deletePersonalTeams($user);
$user->teams()->syncWithoutDetaching([
$rootTeam->id => ['role' => $input['team_role']],
]);
return $user;
});
return $this->succeedWithUser($user);
}
private function deletePersonalTeams(User $user): void
{
// MapleDeploy branding: dashboard-managed users should only see the
// managed instance root team, not an empty personal Coolify team.
$personalTeams = Team::query()
->where('teams.id', '!=', 0)
->where('personal_team', true)
->whereHas('members', fn ($query) => $query->whereKey($user->id))
->get();
foreach ($personalTeams as $team) {
DB::table('team_user')
->where('team_id', $team->id)
->where('user_id', $user->id)
->delete();
DB::table('teams')->where('id', $team->id)->delete();
}
}
private function readPassword(): string
{
return rtrim((string) stream_get_contents(STDIN), "\n");
}
private function succeedWithUser(User $user): int
{
$this->line(json_encode([
'user' => [
'id' => $user->id,
'email' => $user->email,
'name' => $user->name,
],
], JSON_THROW_ON_ERROR));
return self::SUCCESS;
}
private function failWith(string $code): int
{
$this->line(json_encode(['error' => $code], JSON_THROW_ON_ERROR));
return self::FAILURE;
}
}

View file

@ -0,0 +1,61 @@
<?php
namespace App\Console\Commands\Mapledeploy;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class UserDelete extends Command
{
protected $signature = 'mapledeploy:user:delete {user_id : Coolify user id}';
protected $description = 'Delete a Coolify user for MapleDeploy dashboard access management';
public function handle(): int
{
$rawUserId = $this->argument('user_id');
$userId = filter_var($rawUserId, FILTER_VALIDATE_INT, ['options' => ['min_range' => 0]]);
if ($userId === false) {
return $this->failWith('INVALID_USER_ID');
}
if ($userId === 0) {
return $this->failWith('CANNOT_DELETE_ROOT_USER');
}
$user = User::find($userId);
if (! $user) {
$this->line(json_encode([
'deleted' => null,
'alreadyDeleted' => true,
'id' => $userId,
], JSON_THROW_ON_ERROR));
return self::SUCCESS;
}
$deleted = [
'id' => $user->id,
'email' => $user->email,
];
DB::transaction(function () use ($user) {
$user->tokens()->delete();
// MapleDeploy branding: deletion must end any active browser sessions.
DB::table('sessions')->where('user_id', $user->id)->delete();
$user->delete();
});
$this->line(json_encode(['deleted' => $deleted], JSON_THROW_ON_ERROR));
return self::SUCCESS;
}
private function failWith(string $code): int
{
$this->line(json_encode(['error' => $code], JSON_THROW_ON_ERROR));
return self::FAILURE;
}
}

View file

@ -0,0 +1,40 @@
<?php
namespace App\Console\Commands\Mapledeploy;
use App\Models\User;
use Illuminate\Console\Command;
class UserList extends Command
{
protected $signature = 'mapledeploy:user:list';
protected $description = 'List Coolify users for MapleDeploy dashboard access management';
public function handle(): int
{
$users = User::with('teams')
->orderBy('id')
->get()
->map(fn (User $user) => [
'id' => $user->id,
'email' => $user->email,
'name' => $user->name,
'created_at' => $user->created_at?->toISOString(),
'teams' => $user->teams
->map(fn ($team) => [
'id' => $team->id,
'name' => $team->name,
'role' => $team->pivot?->role,
])
->values()
->all(),
])
->values()
->all();
$this->line(json_encode(['users' => $users], JSON_THROW_ON_ERROR));
return self::SUCCESS;
}
}

View file

@ -0,0 +1,55 @@
<?php
namespace App\Console\Commands\Mapledeploy;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
class UserRevoke extends Command
{
protected $signature = 'mapledeploy:user:revoke {user_id : Coolify user id}';
protected $description = 'Revoke a Coolify user login for MapleDeploy dashboard access management';
public function handle(): int
{
$userId = (int) $this->argument('user_id');
if ($userId === 0) {
return $this->failWith('CANNOT_REVOKE_ROOT_USER');
}
$user = User::find($userId);
if (! $user) {
return $this->failWith('USER_NOT_FOUND');
}
$user->forceFill([
'password' => Hash::make(Str::random(64)),
// MapleDeploy branding: OAuth login matches by email, so keep a
// persistent marker that the callback can reject after revocation.
'remember_token' => 'mapledeploy-revoked:'.Str::random(40),
])->save();
$user->tokens()->delete();
// MapleDeploy branding: revocation must end any active browser sessions.
DB::table('sessions')->where('user_id', $user->id)->delete();
$this->line(json_encode([
'revoked' => [
'id' => $user->id,
'email' => $user->email,
],
], JSON_THROW_ON_ERROR));
return self::SUCCESS;
}
private function failWith(string $code): int
{
$this->line(json_encode(['error' => $code], JSON_THROW_ON_ERROR));
return self::FAILURE;
}
}

View file

@ -0,0 +1,107 @@
<?php
namespace App\Console\Commands\Mapledeploy;
use App\Enums\Role;
use App\Models\Team;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
class UserSetPassword extends Command
{
protected $signature = 'mapledeploy:user:set-password
{user_id : Coolify user id}
{--email= : New user email address}
{--name= : New user display name}';
protected $description = 'Set a Coolify user password for MapleDeploy dashboard access management';
public function handle(): int
{
$password = rtrim((string) stream_get_contents(STDIN), "\n");
$updatesOwner = $this->option('email') !== null || $this->option('name') !== null;
$input = [
'password' => $password,
'email' => $this->option('email'),
'name' => $this->option('name'),
];
$rules = ['password' => ['required', 'string', 'min:8']];
if ($updatesOwner) {
$rules['email'] = ['required', 'string', 'email', 'max:255'];
$rules['name'] = ['required', 'string', 'max:255'];
}
$validator = Validator::make($input, $rules);
if ($validator->fails()) {
return $this->failWith('INVALID_INPUT');
}
$user = User::find($this->argument('user_id'));
if (! $user) {
return $this->failWith('USER_NOT_FOUND');
}
$rootTeam = null;
if ((int) $user->id !== 0) {
$rootTeam = Team::find(0);
if (! $rootTeam) {
return $this->failWith('ROOT_TEAM_MISSING');
}
}
$changes = [
'password' => Hash::make($password),
// MapleDeploy branding: clear the revocation marker when the
// dashboard intentionally restores this Coolify login.
'remember_token' => null,
];
if ($updatesOwner) {
$email = Str::lower((string) $input['email']);
if (User::whereEmail($email)->whereKeyNot($user->id)->exists()) {
return $this->failWith('EMAIL_EXISTS');
}
// MapleDeploy branding: claiming root admin transfers the Coolify
// account identity so the previous email holder cannot recover it.
$changes['email'] = $email;
$changes['name'] = $input['name'];
}
DB::transaction(function () use ($user, $changes, $updatesOwner, $rootTeam) {
$user->forceFill($changes)->save();
if ($updatesOwner && ! $user->hasVerifiedEmail()) {
$user->markEmailAsVerified();
}
if ($rootTeam) {
// MapleDeploy branding: matching an existing Coolify user by
// email must grant the same root-team admin access as a newly
// dashboard-created user.
$user->teams()->syncWithoutDetaching([
$rootTeam->id => ['role' => Role::ADMIN->value],
]);
}
// MapleDeploy branding: password resets from the dashboard should
// end browser sessions authenticated with the previous password.
DB::table('sessions')->where('user_id', $user->id)->delete();
});
$this->line(json_encode([
'user' => [
'id' => $user->id,
'email' => $user->email,
'name' => $user->name,
],
], JSON_THROW_ON_ERROR));
return self::SUCCESS;
}
private function failWith(string $code): int
{
$this->line(json_encode(['error' => $code], JSON_THROW_ON_ERROR));
return self::FAILURE;
}
}

View file

@ -82,6 +82,11 @@ public function forgot_password(Request $request)
return response()->json(['message' => 'Transactional emails are not active'], 400);
}
$request->validate([Fortify::email() => 'required|email']);
$user = User::where('email', $request->input(Fortify::email()))->first();
if ($user?->isMapledeployRevoked()) {
// MapleDeploy branding: only the dashboard set-password path can restore revoked users.
return app(SuccessfulPasswordResetLinkRequestResponse::class, ['status' => Password::RESET_LINK_SENT]);
}
$status = Password::broker(config('fortify.passwords'))->sendResetLink(
$request->only(Fortify::email())
);

View file

@ -25,6 +25,12 @@ public function callback(string $provider)
}
$email = strtolower($email);
$user = User::whereEmail($email)->first();
// MapleDeploy branding: dashboard revocation scrambles passwords,
// clears sessions, and marks the user so email-matched OAuth cannot
// reopen access.
if ($user?->isMapledeployRevoked()) {
abort(403, 'User access has been revoked');
}
if (! $user) {
$settings = instanceSettings();
if (! $settings->is_registration_enabled) {

View file

@ -17,6 +17,7 @@
use App\Http\Middleware\EnsureTokenBelongsToCurrentTeamMember;
use App\Http\Middleware\PreventRequestsDuringMaintenance;
use App\Http\Middleware\RedirectIfAuthenticated;
use App\Http\Middleware\RejectMapledeployRevokedUser;
use App\Http\Middleware\TrimStrings;
use App\Http\Middleware\TrustHosts;
use App\Http\Middleware\TrustProxies;
@ -75,6 +76,7 @@ class Kernel extends HttpKernel
ShareErrorsFromSession::class,
VerifyCsrfToken::class,
SubstituteBindings::class,
RejectMapledeployRevokedUser::class,
CheckForcePasswordReset::class,
DecideWhatToDoWithUser::class,

View file

@ -16,6 +16,16 @@ public function handle(Request $request, Closure $next): Response
$currentTeam = auth()->user()?->recreate_personal_team();
refreshSession($currentTeam);
}
$preferredTeam = auth()?->user()?->mapledeployPreferredTeam();
if (
$preferredTeam &&
$preferredTeam->id === 0 &&
auth()?->user()?->currentTeam()?->id !== 0
) {
// MapleDeploy branding: repair sessions that landed in the empty
// personal team before dashboard-managed root-team access existed.
refreshSession($preferredTeam);
}
if (auth()?->user()?->currentTeam()) {
refreshSession(auth()->user()->currentTeam());
// A team is already active; the selection screen no longer applies.

View file

@ -0,0 +1,37 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class RejectMapledeployRevokedUser
{
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
$user = auth()->user();
if (! $user?->isMapledeployRevoked()) {
return $next($request);
}
// MapleDeploy branding: revocation is marked on the user row so old
// browser sessions are rejected even when SESSION_DRIVER is not database.
auth()->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
if ($request->routeIs('login') || $request->path() === 'login') {
return $next($request);
}
return redirect()->route('login')->withErrors([
'email' => __('auth.failed'),
]);
}
}

View file

@ -98,7 +98,7 @@ protected static function boot()
$team = [
'name' => $user->name."'s Team",
'personal_team' => true,
'show_boarding' => true,
'show_boarding' => false,
];
if ($user->id === 0) {
$team['id'] = 0;
@ -221,7 +221,7 @@ public function recreate_personal_team()
$team = [
'name' => $this->name."'s Team",
'personal_team' => true,
'show_boarding' => true,
'show_boarding' => false,
];
if ($this->id === 0) {
$team['id'] = 0;
@ -234,6 +234,20 @@ public function recreate_personal_team()
return $new_team;
}
public function mapledeployPreferredTeam(): ?Team
{
// MapleDeploy branding: dashboard-managed users are attached to the
// root team so they can administer the customer's managed instance.
$rootTeam = $this->teams->firstWhere('id', 0);
$rootRole = data_get($rootTeam, 'pivot.role');
if ($rootTeam && ($rootRole === 'admin' || $rootRole === 'owner')) {
return $rootTeam;
}
return $this->teams->firstWhere('personal_team', true)
?? $this->teams->first();
}
public function createToken(string $name, array $abilities = ['*'], ?DateTimeInterface $expiresAt = null)
{
$plainTextToken = sprintf(
@ -294,9 +308,19 @@ public function sendVerificationEmail()
public function sendPasswordResetNotification($token): void
{
if ($this->isMapledeployRevoked()) {
return;
}
$this?->notify(new TransactionalEmailsResetPassword($token));
}
public function isMapledeployRevoked(): bool
{
// MapleDeploy branding: dashboard-managed revocation stores a persistent marker.
return str_starts_with((string) $this->remember_token, 'mapledeploy-revoked:');
}
public function isAdmin()
{
return $this->role() === 'admin' || $this->role() === 'owner';

View file

@ -43,13 +43,13 @@ public function boot(): void
{
Fortify::createUsersUsing(CreateNewUser::class);
Fortify::registerView(function () {
$isFirstUser = User::count() === 0;
$settings = instanceSettings();
if (! $settings->is_registration_enabled) {
return redirect()->route('login');
}
$isFirstUser = User::count() === 0;
return view('auth.register', [
'isFirstUser' => $isFirstUser,
]);
@ -59,8 +59,11 @@ public function boot(): void
$settings = instanceSettings();
$enabled_oauth_providers = OauthSetting::where('enabled', true)->get();
$users = User::count();
if ($users == 0) {
// If there are no users, redirect to registration
// MapleDeploy branding: public registration is disabled by default
// because the dashboard creates the first admin over SSH. Do not
// redirect to /register in that fail-closed state, or fresh/failed
// provisioning loops between login and registration.
if ($users == 0 && $settings->is_registration_enabled) {
return redirect()->route('register');
}
@ -94,6 +97,12 @@ public function boot(): void
} else {
// Restore the last active team; only fall back when unambiguous.
$team = $user->resolveStoredTeam();
// MapleDeploy branding: without a stored choice, root-team
// admins should land in the managed instance team, not an
// empty personal team or the team-selection screen.
if (! $team) {
$team = $user->mapledeployPreferredTeam();
}
if (! $team && $user->teams->isEmpty()) {
$team = $user->recreate_personal_team();
}

View file

@ -653,13 +653,14 @@ function get_route_parameters(): array
function get_latest_sentinel_version(): string
{
// MapleDeploy branding: our versions.json omits the sentinel key, so fall back to 'latest' (matches upstream's fallback in CheckAndStartSentinelJob)
try {
$response = Http::get(config('constants.coolify.versions_url'));
$versions = $response->json();
return data_get($versions, 'coolify.sentinel.version');
} catch (Throwable) {
return '0.0.0';
return data_get($versions, 'coolify.sentinel.version') ?? 'latest';
} catch (\Throwable) {
return 'latest';
}
}
function get_latest_version_of_coolify(): string

View file

@ -1,27 +1,28 @@
<?php
return [
// MapleDeploy branding: registry pointed to Forgejo, auto-update disabled by default
'coolify' => [
'version' => env('COOLIFY_VERSION') ?: '4.3.19',
'helper_version' => '1.0.16',
'realtime_version' => '1.0.19',
'railpack_version' => '0.23.0',
'self_hosted' => env('SELF_HOSTED', true),
'autoupdate' => env('AUTOUPDATE'),
'autoupdate' => env('AUTOUPDATE', false),
'base_config_path' => env('BASE_CONFIG_PATH', '/data/coolify'),
'registry_url' => env('REGISTRY_URL', 'docker.io'),
'helper_image' => env('HELPER_IMAGE', env('REGISTRY_URL', 'docker.io').'/coollabsio/coolify-helper'),
'realtime_image' => env('REALTIME_IMAGE', env('REGISTRY_URL', 'docker.io').'/coollabsio/coolify-realtime'),
'registry_url' => env('REGISTRY_URL', 'forgejo.mapledeploy.ca'),
'helper_image' => env('HELPER_IMAGE', 'ghcr.io/coollabsio/coolify-helper'),
'realtime_image' => env('REALTIME_IMAGE', 'ghcr.io/coollabsio/coolify-realtime'),
'is_windows_docker_desktop' => env('IS_WINDOWS_DOCKER_DESKTOP', false),
'cdn_url' => env('CDN_URL', 'https://cdn.coollabs.io'),
'versions_url' => env('VERSIONS_URL', env('CDN_URL', 'https://cdn.coollabs.io').'/coolify/versions.json'),
'upgrade_script_url' => env('UPGRADE_SCRIPT_URL', env('CDN_URL', 'https://cdn.coollabs.io').'/coolify/upgrade.sh'),
'releases_url' => env('RELEASES_URL', 'https://cdn.coollabs.io/coolify/releases.json'),
'cdn_url' => env('CDN_URL', 'https://updates.mapledeploy.ca'),
'versions_url' => env('VERSIONS_URL', env('CDN_URL', 'https://updates.mapledeploy.ca').'/coolify/versions.json'),
'upgrade_script_url' => env('UPGRADE_SCRIPT_URL', env('CDN_URL', 'https://updates.mapledeploy.ca').'/coolify/upgrade.sh'),
'releases_url' => 'https://cdn.coolify.io/releases.json',
],
'urls' => [
'docs' => 'https://coolify.io/docs',
'contact' => 'https://coolify.io/docs/contact',
'docs' => 'https://mapledeploy.ca/docs',
'contact' => 'https://mapledeploy.ca/contact',
],
'services' => [
@ -94,8 +95,9 @@
'verification_code_expiry_minutes' => 10,
],
'sentry' => [
'sentry_dsn' => env('SENTRY_DSN'),
// MapleDeploy branding: telemetry disabled
'sentry' => [ // disabled by MapleDeploy
'sentry_dsn' => null,
],
'sentinel' => [
@ -117,7 +119,6 @@
],
'webhooks' => [
'feedback_discord_webhook' => env('FEEDBACK_DISCORD_WEBHOOK'),
'dev_webhook' => env('SERVEO_URL'),
],

View file

@ -21,6 +21,13 @@
return $fallbackHosts === [] ? ['coolify-db'] : $fallbackHosts;
};
$pgsqlOptions = [];
if (defined('Pdo\Pgsql::ATTR_DISABLE_PREPARES')) {
$pgsqlOptions[Pgsql::ATTR_DISABLE_PREPARES] = env('DB_DISABLE_PREPARES', false);
} elseif (defined('\PDO::PGSQL_ATTR_DISABLE_PREPARES')) {
$pgsqlOptions[PDO::PGSQL_ATTR_DISABLE_PREPARES] = env('DB_DISABLE_PREPARES', false);
}
$pgsql = [
'driver' => 'pgsql',
'url' => env('DATABASE_URL'),
@ -34,9 +41,7 @@
'prefix_indexes' => true,
'search_path' => 'public',
'sslmode' => 'prefer',
'options' => [
(defined('Pdo\Pgsql::ATTR_DISABLE_PREPARES') ? Pgsql::ATTR_DISABLE_PREPARES : PDO::PGSQL_ATTR_DISABLE_PREPARES) => env('DB_DISABLE_PREPARES', false),
],
'options' => $pgsqlOptions,
];
/*
@ -97,7 +102,7 @@
'testing' => [
'driver' => 'sqlite',
'database' => ':memory:',
'database' => env('DB_DATABASE', ':memory:'),
'prefix' => '',
'foreign_key_constraints' => true,
],

View file

@ -118,7 +118,7 @@
'navigate' => [
'show_progress_bar' => true,
'progress_bar_color' => '#6b16ed',
'progress_bar_color' => '#fde047', // MapleDeploy branding: warning yellow
],
/*

View file

@ -2,8 +2,8 @@
return [
// @see https://docs.sentry.io/product/sentry-basics/dsn-explainer/
'dsn' => config('constants.sentry.sentry_dsn'),
// Sentry DSN disabled by MapleDeploy.
'dsn' => config('constants.sentry.sentry_dsn'), // disabled by MapleDeploy
// The release version of your application
// Example with dynamic git hash: trim(exec('git --git-dir ' . base_path('.git') . ' log --pretty="%h" -n1 HEAD'))
@ -40,7 +40,7 @@
'tracing' => [
// Trace queue jobs as their own transactions
'queue_job_transactions' => env('SENTRY_TRACE_QUEUE_ENABLED', false),
'queue_job_transactions' => env('SENTRY_TRACE_QUEUE_ENABLED', false), // disabled by MapleDeploy
// Capture queue jobs as spans when executed on the sync driver
'queue_jobs' => true,
@ -61,12 +61,12 @@
'http_client_requests' => true,
// Capture Redis operations as spans (this enables Redis events in Laravel)
'redis_commands' => env('SENTRY_TRACE_REDIS_COMMANDS', false),
'redis_commands' => env('SENTRY_TRACE_REDIS_COMMANDS', false), // disabled by MapleDeploy
// Try to find out where the Redis command originated from and add it to the command spans
'redis_origin' => true,
// Indicates if the tracing integrations supplied by Sentry should be loaded
// Indicates if the tracing integrations supplied by Sentry should be loaded; disabled by MapleDeploy.
'default_integrations' => true,
// Indicates that requests without a matching route should be traced
@ -74,12 +74,12 @@
],
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#send-default-pii
'send_default_pii' => env('SENTRY_SEND_DEFAULT_PII', false),
'send_default_pii' => env('SENTRY_SEND_DEFAULT_PII', false), // disabled by MapleDeploy
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#traces-sample-rate
'enable_tracing' => env('SENTRY_ENABLE_TRACING', false),
'enable_tracing' => env('SENTRY_ENABLE_TRACING', false), // disabled by MapleDeploy
'traces_sample_rate' => 0.2,
'profiles_sample_rate' => env('SENTRY_PROFILES_SAMPLE_RATE') === null ? null : (float) env('SENTRY_PROFILES_SAMPLE_RATE'),
'profiles_sample_rate' => env('SENTRY_PROFILES_SAMPLE_RATE') === null ? null : (float) env('SENTRY_PROFILES_SAMPLE_RATE'), // disabled by MapleDeploy
];

View file

@ -13,7 +13,7 @@ public function up(): void
{
Schema::table('instance_settings', function (Blueprint $table) {
$table->boolean('is_dns_validation_enabled')->default(true);
$table->string('custom_dns_servers')->nullable()->default('1.1.1.1');
$table->string('custom_dns_servers')->nullable()->default('149.112.121.10,149.112.122.10');
});
}

View file

@ -0,0 +1,47 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
$columns = array_values(array_filter(
['setup_token', 'setup_callback_url'],
fn (string $column) => Schema::hasColumn('instance_settings', $column),
));
if ($columns === []) {
return;
}
Schema::table('instance_settings', function (Blueprint $table) use ($columns) {
// MapleDeploy branding: remove legacy one-time setup columns from customer instances.
$table->dropColumn($columns);
});
}
public function down(): void
{
$missingColumns = array_values(array_filter(
['setup_token', 'setup_callback_url'],
fn (string $column) => ! Schema::hasColumn('instance_settings', $column),
));
if ($missingColumns === []) {
return;
}
Schema::table('instance_settings', function (Blueprint $table) use ($missingColumns) {
if (in_array('setup_token', $missingColumns, true)) {
$table->text('setup_token')->nullable();
}
if (in_array('setup_callback_url', $missingColumns, true)) {
$table->text('setup_callback_url')->nullable();
}
});
}
};

View file

@ -0,0 +1,53 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
// MapleDeploy branding: threshold-based Docker cleanup by default.
//
// Upstream defaults to a forced cleanup once a day (00:00). On a managed
// instance whose only server is the VM itself, a build-heavy app can fill the
// disk between two daily runs (titanreach, 2026-08-29: ~30 deploys of a 2.5 GB
// image in one evening, ENOSPC mid-build, deployment queue stuck). The
// scheduler dispatches on docker_cleanup_frequency regardless of the force
// flag; with force off, the job only prunes when usage exceeds the threshold.
// Checking every 10 minutes and pruning above 75% catches fast fills without
// pruning on servers that do not need it.
return new class extends Migration
{
private const UPSTREAM = ['force' => true, 'frequency' => '0 0 * * *', 'threshold' => 80];
private const MAPLEDEPLOY = ['force' => false, 'frequency' => '*/10 * * * *', 'threshold' => 75];
public function up(): void
{
Schema::table('server_settings', function (Blueprint $table) {
$table->boolean('force_docker_cleanup')->default(self::MAPLEDEPLOY['force'])->change();
$table->string('docker_cleanup_frequency')->default(self::MAPLEDEPLOY['frequency'])->change();
$table->integer('docker_cleanup_threshold')->default(self::MAPLEDEPLOY['threshold'])->change();
});
// Only rows still on the untouched upstream default are moved, so a
// setting an operator chose deliberately is left alone.
DB::table('server_settings')
->where('force_docker_cleanup', self::UPSTREAM['force'])
->where('docker_cleanup_frequency', self::UPSTREAM['frequency'])
->where('docker_cleanup_threshold', self::UPSTREAM['threshold'])
->update([
'force_docker_cleanup' => self::MAPLEDEPLOY['force'],
'docker_cleanup_frequency' => self::MAPLEDEPLOY['frequency'],
'docker_cleanup_threshold' => self::MAPLEDEPLOY['threshold'],
]);
}
public function down(): void
{
Schema::table('server_settings', function (Blueprint $table) {
$table->boolean('force_docker_cleanup')->default(self::UPSTREAM['force'])->change();
$table->string('docker_cleanup_frequency')->default(self::UPSTREAM['frequency'])->change();
$table->integer('docker_cleanup_threshold')->default(self::UPSTREAM['threshold'])->change();
});
}
};

View file

@ -15,7 +15,9 @@ public function run(): void
{
InstanceSettings::create([
'id' => 0,
'is_registration_enabled' => true,
// MapleDeploy branding: dashboard provisioning creates the first
// admin over SSH, so public registration must fail closed.
'is_registration_enabled' => false,
'is_api_enabled' => isDev(),
'smtp_enabled' => true,
'smtp_host' => 'coolify-mail',

View file

@ -57,6 +57,9 @@ public function run(): void
if (InstanceSettings::find(0) == null) {
InstanceSettings::create([
'id' => 0,
// MapleDeploy branding: dashboard provisioning creates the
// first admin over SSH, so public registration must fail closed.
'is_registration_enabled' => false,
]);
}

View file

@ -1,6 +1,6 @@
services:
coolify:
image: "${REGISTRY_URL:-docker.io}/coollabsio/coolify:${LATEST_IMAGE:-latest}"
image: "${REGISTRY_URL:-forgejo.mapledeploy.ca}/rosslh/coolify:${LATEST_IMAGE:-latest}"
volumes:
- type: bind
source: /data/coolify/source/.env
@ -62,7 +62,7 @@ services:
retries: 10
timeout: 2s
soketi:
image: '${REGISTRY_URL:-docker.io}/coollabsio/coolify-realtime:1.0.19'
image: 'ghcr.io/coollabsio/coolify-realtime:1.0.19' # MapleDeploy branding: pinned to ghcr.io, not mirrored
ports:
- "${SOKETI_PORT:-6001}:6001"
- "6002:6002"

View file

@ -147,6 +147,30 @@ COPY --chown=www-data:www-data composer.json composer.lock ./
COPY --chown=www-data:www-data app ./app
COPY --chown=www-data:www-data bootstrap ./bootstrap
COPY --chown=www-data:www-data config ./config
# MapleDeploy: inject build version into constants.php at build time.
# The version in git stays at the upstream value to avoid rebase conflicts.
# CI passes the timestamped version (e.g. 4.0.0-beta.468.202603140006) as a build arg.
# The substitution replaces the whole 'version' line rather than matching a
# quoted literal, so it works for both upstream constant shapes:
# 'version' => '4.2.0',
# 'version' => env('COOLIFY_VERSION') ?: '4.3.12',
# and it collapses the env() indirection to a literal so the image always
# reports the version it was built as, regardless of runtime env.
# The grep is a hard gate: a silently unmatched sed used to ship an image
# tagged with a timestamped version while reporting the upstream base version.
ARG MAPLEDEPLOY_VERSION=""
RUN if [ -n "$MAPLEDEPLOY_VERSION" ]; then \
sed -i -E "s|^([[:space:]]*'version' => ).*|\1'$MAPLEDEPLOY_VERSION',|" config/constants.php; \
if ! grep -qF "'version' => '$MAPLEDEPLOY_VERSION'," config/constants.php; then \
echo "ERROR: MapleDeploy version injection failed: no 'version' entry in config/constants.php matched the expected shape."; \
echo " See docs/operations/COOLIFY_FORK.md (How version bumping works) before changing this step."; \
grep -n "'version' =>" config/constants.php || true; \
exit 1; \
fi; \
chown www-data:www-data config/constants.php; \
fi
COPY --chown=www-data:www-data database ./database
COPY --chown=www-data:www-data lang ./lang
COPY --chown=www-data:www-data public ./public

View file

@ -19,7 +19,7 @@
"auth.register_now": "Register",
"auth.logout": "Logout",
"auth.register": "Register",
"auth.registration_disabled": "Registration is disabled. Please contact the administrator.",
"auth.registration_disabled": "Set up server access in the MapleDeploy dashboard.",
"auth.reset_password": "Reset password",
"auth.failed": "These credentials do not match our records.",
"auth.failed.callback": "Failed to process callback from login provider.",
@ -42,4 +42,4 @@
"resource.delete_configurations": "Permanently delete all configuration files from the server.",
"database.delete_backups_locally": "All backups will be permanently deleted from local storage.",
"warning.sslipdomain": "Your configuration is saved, but sslip domain with https is <span class='dark:text-red-500 text-red-500 font-bold'>NOT</span> recommended, because Let's Encrypt servers with this public domain are rate limited (SSL certificate validation will fail). <br><br>Use your own domain instead."
}
}

View file

@ -17,7 +17,7 @@
"auth.register_now": "S'enregistrer",
"auth.logout": "Déconnexion",
"auth.register": "S'enregistrer",
"auth.registration_disabled": "L'enregistrement est désactivé. Merci de contacter l'administrateur.",
"auth.registration_disabled": "Configurez laccès au serveur dans le tableau de bord MapleDeploy.",
"auth.reset_password": "Réinitialiser le mot de passe",
"auth.failed": "Aucune correspondance n'a été trouvée pour les informations d'identification renseignées.",
"auth.failed.callback": "Erreur lors du processus de retour de la plateforme de connexion.",
@ -40,4 +40,4 @@
"resource.delete_configurations": "Supprimer définitivement tous les fichiers de configuration du serveur.",
"database.delete_backups_locally": "Toutes les sauvegardes seront définitivement supprimées du stockage local.",
"warning.sslipdomain": "Votre configuration est enregistrée, mais l'utilisation du domaine sslip avec https <span class='dark:text-red-500 text-red-500 font-bold'>N'EST PAS</span> recommandée, car les serveurs Let's Encrypt avec ce domaine public sont limités en taux (la validation du certificat SSL échouera). <br><br>Utilisez plutôt votre propre domaine."
}
}

View file

@ -14,63 +14,83 @@
@custom-variant dark (&:where(.dark, .dark *));
@theme {
--font-sans: 'Geist Sans', Inter, sans-serif;
--font-mono: 'Geist Mono', 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
--font-geist-sans: 'Geist Sans', Inter, sans-serif;
--font-logs: 'Geist Mono', 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
--color-base: oklch(17.3% 0 0);
--color-warning: oklch(88.13% 0.1507 91.7);
--color-warning-50: oklch(98.73% 0.0262 102.21);
--color-warning-100: oklch(97.29% 0.0693 103.19);
--color-warning-200: oklch(94.51% 0.1243 101.54);
--color-warning-300: oklch(90.52% 0.1657 98.11);
--color-warning-400: oklch(88.13% 0.1507 91.7);
--color-warning-500: oklch(86.06% 0.1731 91.94);
--color-warning-600: oklch(68.06% 0.1423 75.83);
--color-warning-700: oklch(55.38% 0.1207 66.44);
--color-warning-800: oklch(47.62% 0.1034 61.91);
--color-warning-900: oklch(42.1% 0.0897 57.71);
/* MapleDeploy branding: Tailwind's built-in stone scale, copied verbatim so
dark ladder tokens below can be authored as color-mix() of adjacent stone
steps (Tailwind only emits --color-stone-* vars for utilities actually
used, so the tokens cannot reference them directly). Keep in sync with
Tailwind v4's stone palette. */
:root {
--md-stone-100: oklch(97% 0.001 106.424);
--md-stone-200: oklch(92.3% 0.003 48.717);
--md-stone-300: oklch(86.9% 0.005 56.366);
--md-stone-400: oklch(70.9% 0.01 56.259);
--md-stone-500: oklch(55.3% 0.013 58.071);
--md-stone-600: oklch(44.4% 0.011 73.639);
--md-stone-700: oklch(37.4% 0.01 67.558);
--md-stone-800: oklch(26.8% 0.007 34.298);
--md-stone-900: oklch(21.6% 0.006 56.043);
--md-stone-950: oklch(14.7% 0.004 49.25);
}
/* MapleDeploy branding: Canadian red accent, stone greys */
@theme {
--font-sans: Inter, sans-serif;
--font-display: 'Overlock', sans-serif;
--font-mono: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
--font-logs: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
--color-base: color-mix(in oklab, var(--md-stone-900) 40%, var(--md-stone-950)); /* MapleDeploy branding: stone-900/950 mix, L≈17.5 (upstream 17.3) */
--color-warning: #fde047; /* MapleDeploy branding: warning palette */
--color-warning-50: #fefce8;
--color-warning-100: #fef9c3;
--color-warning-200: #fef08a;
--color-warning-300: #fde047;
--color-warning-400: #facc15;
--color-warning-500: #eab308;
--color-warning-600: #ca8a04;
--color-warning-700: #a16207;
--color-warning-800: #854d0e;
--color-warning-900: #713f12;
--color-success: oklch(72.27% 0.192 149.58);
--color-error: oklch(57.71% 0.2152 27.33);
--color-coollabs-50: oklch(96.33% 0.0206 301.15);
--color-coollabs: oklch(49.65% 0.2709 289.33);
--color-coollabs-100: oklch(52.34% 0.287 289.16);
--color-coollabs-200: oklch(43.76% 0.2369 289.82);
--color-coollabs-300: oklch(38.04% 0.2031 290.47);
--color-coolgray-100: oklch(20.9% 0 0);
--color-coolgray-200: oklch(24.35% 0 0);
--color-coolgray-300: oklch(26.03% 0 0);
--color-coolgray-400: oklch(27.68% 0 0);
--color-coolgray-500: oklch(31.71% 0 0);
--color-error: #dc281c; /* MapleDeploy branding: red palette hue-normalized */
--color-coollabs-50: #fef3f1; /* MapleDeploy branding: coollabs -> red */
--color-coollabs: #d52b1f;
--color-coollabs-100: #f34d3d;
--color-coollabs-200: #bc251a;
--color-coollabs-300: #9c2117;
--color-coolgray-100: color-mix(in oklab, var(--md-stone-900) 90%, var(--md-stone-950)); /* MapleDeploy branding: stone-900/950 mix, L≈20.9 */
--color-coolgray-200: color-mix(in oklab, var(--md-stone-800) 55%, var(--md-stone-900)); /* MapleDeploy branding: stone-800/900 mix, L≈24.5 */
--color-coolgray-300: color-mix(in oklab, var(--md-stone-800) 85%, var(--md-stone-900)); /* MapleDeploy branding: stone-800/900 mix, L≈26.0 */
--color-coolgray-400: color-mix(in oklab, var(--md-stone-700) 10%, var(--md-stone-800)); /* MapleDeploy branding: stone-700/800 mix, L≈27.9 */
--color-coolgray-500: color-mix(in oklab, var(--md-stone-700) 45%, var(--md-stone-800)); /* MapleDeploy branding: stone-700/800 mix, L≈31.6 */
/* Graphite design language (ported from ref/frontend). Layered neutral
surfaces + translucent hairlines. See DESIGN.md. */
/* Content canvas: the darkest shell layer (sRGB ~10). Hex, not oklch:
oklch lightness compresses to near-black below ~15%, so oklch(7.5%)
renders as sRGB 1 with no visible step. */
--color-app: oklch(14.48% 0 0);
--color-app: var(--md-stone-950); /* MapleDeploy branding: stone-950 (upstream 14.48 ≈ 14.7) */
/* Sidebar + topbar chrome (sRGB ~20): a clear step lighter than the content
canvas so the shell chrome separates from the content area. */
--color-panel: oklch(19.13% 0 0);
--color-panel: color-mix(in oklab, var(--md-stone-900) 65%, var(--md-stone-950)); /* MapleDeploy branding: stone-900/950 mix, L≈19.2 (upstream 19.13) */
/* Pure neutral (r=g=b). Were cool-tinted (#161618/#1c1c1e/#26262a, b>r),
which clashed with the neutral panel ladder. */
--color-surface: oklch(20.02% 0 0);
--color-raised: oklch(22.64% 0 0);
--color-selected: oklch(26.86% 0 0);
--color-fg: oklch(96.12% 0 0);
--color-fg-dim: oklch(76.99% 0 0);
--color-surface: color-mix(in oklab, var(--md-stone-900) 75%, var(--md-stone-950)); /* MapleDeploy branding: stone-900/950 mix, L≈19.9 (upstream 20.02) */
--color-raised: color-mix(in oklab, var(--md-stone-800) 20%, var(--md-stone-900)); /* MapleDeploy branding: stone-800/900 mix, L≈22.6 */
--color-selected: var(--md-stone-800); /* MapleDeploy branding: stone-800 (upstream 26.86 ≈ 26.8) */
--color-fg: var(--md-stone-100); /* MapleDeploy branding: stone-100 (upstream 96.12 ≈ 97) */
--color-fg-dim: color-mix(in oklab, var(--md-stone-300) 40%, var(--md-stone-400)); /* MapleDeploy branding: stone-300/400 mix, L≈77.3 */
/* Tertiary text. Raised from #6e6e74 (3.78:1 on base, failed WCAG AA)
to #7e7e84 so 13-14px text clears 4.5:1 on the dark surface ladder. */
--color-fg-faint: oklch(59.31% 0 0);
--color-accent: oklch(49.65% 0.2709 289.33);
--color-fg-faint: color-mix(in oklab, var(--md-stone-400) 25%, var(--md-stone-500)); /* MapleDeploy branding: stone-400/500 mix, L≈59.2 */
--color-accent: #d52b1f; /* MapleDeploy branding: accent -> red-600 */
--color-accent-foreground: oklch(100.0% 0 0);
--color-hairline: oklch(100.0% 0 0 / 0.08);
--color-nav-text: oklch(43.86% 0 0);
--color-nav-muted: oklch(51.03% 0 0);
--color-nav-active: oklch(20.46% 0 0);
--color-log: oklch(15.91% 0 0);
--color-nav-text: var(--md-stone-600); /* MapleDeploy branding: stone-600 (upstream 43.86 ≈ 44.4) */
--color-nav-muted: color-mix(in oklab, var(--md-stone-500) 60%, var(--md-stone-600)); /* MapleDeploy branding: stone-500/600 mix, L≈50.9 */
--color-nav-active: color-mix(in oklab, var(--md-stone-900) 85%, var(--md-stone-950)); /* MapleDeploy branding: stone-900/950 mix, L≈20.6 */
--color-log: color-mix(in oklab, var(--md-stone-900) 20%, var(--md-stone-950)); /* MapleDeploy branding: stone-900/950 mix, L≈16.1 */
--shadow-modal: 0 24px 64px rgba(0, 0, 0, 0.55), 0 4px 16px rgba(0, 0, 0, 0.4);
/* One restrained lift shared by every dropdown/menu/listbox panel and the
@ -84,6 +104,46 @@ @theme {
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);
--ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);
--ease-drawer: cubic-bezier(0.32, 0.72, 0, 1);
/* MapleDeploy branding: override Tailwind's red scale so red-* utilities match the brand hue */
--color-red-50: #fef1ef;
--color-red-100: #ffe3df;
--color-red-200: #ffcec5;
--color-red-300: #feaa9d;
--color-red-400: #fb7968;
--color-red-500: #f34d3d;
--color-red-600: #d52b1f;
--color-red-700: #bc251a;
--color-red-800: #9c2117;
--color-red-900: #812219;
--color-red-950: #460d08;
/* MapleDeploy branding: remap neutral and gray -> stone for warm tone consistency with marketing/dashboard.
Upstream uses neutral for text/borders and gray for backgrounds/surfaces. Remapping both to stone
warms the entire UI to match our design system, covering ~1000 class references without touching
any Blade templates. */
--color-neutral-50: oklch(98.5% 0.001 106.423);
--color-neutral-100: oklch(97% 0.001 106.424);
--color-neutral-200: oklch(92.3% 0.003 48.717);
--color-neutral-300: oklch(86.9% 0.005 56.366);
--color-neutral-400: oklch(70.9% 0.01 56.259);
--color-neutral-500: oklch(55.3% 0.013 58.071);
--color-neutral-600: oklch(44.4% 0.011 73.639);
--color-neutral-700: oklch(37.4% 0.01 67.558);
--color-neutral-800: oklch(26.8% 0.007 34.298);
--color-neutral-900: oklch(21.6% 0.006 56.043);
--color-neutral-950: oklch(14.7% 0.004 49.25);
--color-gray-50: oklch(98.5% 0.001 106.423);
--color-gray-100: oklch(97% 0.001 106.424);
--color-gray-200: oklch(92.3% 0.003 48.717);
--color-gray-300: oklch(86.9% 0.005 56.366);
--color-gray-400: oklch(70.9% 0.01 56.259);
--color-gray-500: oklch(55.3% 0.013 58.071);
--color-gray-600: oklch(44.4% 0.011 73.639);
--color-gray-700: oklch(37.4% 0.01 67.558);
--color-gray-800: oklch(26.8% 0.007 34.298);
--color-gray-900: oklch(21.6% 0.006 56.043);
--color-gray-950: oklch(14.7% 0.004 49.25);
}
/* Standard buttons have a compact physical edge. Hover lifts the face by 1px;
@ -359,7 +419,8 @@ @layer components {
*/
html,
body {
@apply w-full min-h-full bg-neutral-50 dark:bg-app dark:text-fg-dim;
/* MapleDeploy branding: text-stone-800 body text matches marketing/dashboard */
@apply w-full min-h-full text-stone-800 bg-neutral-50 dark:bg-app dark:text-fg-dim;
}
body {
@ -402,21 +463,35 @@ button[isHighlighted]:not(:disabled) {
}
h1 {
@apply text-[24px] leading-7 font-semibold tracking-tight dark:text-white;
/* MapleDeploy branding: font-display (Overlock) on large semantic headings.
h4 (16px, body size) deliberately stays on the body font. */
@apply text-[24px] leading-7 font-semibold tracking-tight font-display dark:text-white;
}
h2 {
@apply text-xl font-bold dark:text-white;
@apply text-xl font-bold font-display dark:text-white;
}
h3 {
@apply text-lg font-bold dark:text-white;
@apply text-lg font-bold font-display dark:text-white;
}
h4 {
@apply text-base font-bold dark:text-white;
}
/* MapleDeploy branding: Overlock is a display face and only reads well at
heading sizes (>= 18px). Headings downsized below that via text-size
utilities (e.g. 13-15px card and section titles) fall back to the body
font instead of inheriting font-display from the base heading styles. */
:is(h1, h2, h3):is(
[class*="text-[10"], [class*="text-[11"], [class*="text-[12"],
[class*="text-[13"], [class*="text-[14"], [class*="text-[15"],
[class*="text-[16"], [class*="text-[17"],
[class*="text-xs"], [class*="text-sm"], [class*="text-base"]) {
font-family: var(--font-sans);
}
a {
@apply hover:text-black dark:hover:text-white;
}
@ -582,8 +657,8 @@ .application-console-shell,
.terminal-fullscreen-shell {
position: relative;
isolation: isolate;
color: #f2f2f2;
background-color: #121214;
color: var(--md-stone-100); /* MapleDeploy branding: stone-100 (was #f2f2f2) */
background-color: color-mix(in oklab, var(--md-stone-900) 50%, var(--md-stone-950)); /* MapleDeploy branding: stone-900/950 mix (was #121214) */
}
.application-console-shell {
@ -631,7 +706,7 @@ html:not(.dark) .terminal-fullscreen-shell[data-console-theme="system"] .termina
html.dark .application-console-shell[data-console-theme="system"],
html.dark .terminal-fullscreen-shell[data-console-theme="system"] {
--console-theme-background: oklch(18.31% 0.004 285.99);
--console-theme-background: color-mix(in oklab, var(--md-stone-900) 50%, var(--md-stone-950)); /* MapleDeploy branding: stone-900/950 mix, L≈18.2 (upstream 18.31) */
--console-theme-border: rgb(255 255 255 / 0.08);
}
@ -1127,40 +1202,40 @@ :root {
}
.dark {
--color-accent: oklch(88.13% 0.1507 91.7);
--color-accent: #fde047; /* MapleDeploy branding: dark-mode accent -> warning */
/* Dark surface ladder in hex (sRGB), not oklch: oklch lightness compresses
to near-black below ~15%, so oklch(15%) rendered as sRGB 11 and cards
could not lift off the content canvas (sRGB 10). These give clear,
visible steps: content 10 -> elevated 22 -> base 28 -> recessed 34.
Pure neutral (r=g=b), matching the neutral borders/text and the light
ladder, so every panel shares one temperature (no blue cast). */
--coollabs-canvas: oklch(14.48% 0 0);
--coollabs-elevated: oklch(20.02% 0 0);
--coollabs-recessed: oklch(25.2% 0 0);
--coollabs-base: oklch(22.64% 0 0);
--coollabs-fill: oklch(29.31% 0 0);
--coollabs-line: oklch(32% 0 0);
--coollabs-canvas: var(--md-stone-950); /* MapleDeploy branding: stone-950 */
--coollabs-elevated: color-mix(in oklab, var(--md-stone-900) 75%, var(--md-stone-950)); /* MapleDeploy branding: stone-900/950 mix, L≈19.9 */
--coollabs-recessed: color-mix(in oklab, var(--md-stone-800) 70%, var(--md-stone-900)); /* MapleDeploy branding: stone-800/900 mix, L≈25.2 */
--coollabs-base: color-mix(in oklab, var(--md-stone-800) 20%, var(--md-stone-900)); /* MapleDeploy branding: stone-800/900 mix, L≈22.6 */
--coollabs-fill: color-mix(in oklab, var(--md-stone-700) 25%, var(--md-stone-800)); /* MapleDeploy branding: stone-700/800 mix, L≈29.5 */
--coollabs-line: color-mix(in oklab, var(--md-stone-700) 50%, var(--md-stone-800)); /* MapleDeploy branding: stone-700/800 mix, L≈32.1 */
/* Crisped from 26.9% (1.36:1 on canvas) to 32% (1.63:1) so dark cards
and tables show a visible edge instead of blending into the canvas. */
--coollabs-hairline: oklch(32% 0 0);
--coollabs-subtle: oklch(70.8% 0 0);
--coollabs-hairline: color-mix(in oklab, var(--md-stone-700) 50%, var(--md-stone-800)); /* MapleDeploy branding: stone-700/800 mix, L≈32.1 */
--coollabs-subtle: var(--md-stone-400); /* MapleDeploy branding: stone-400 (upstream 70.8 ≈ 70.9) */
/* Neutral (was #a8a8b0 / #7a7a84, both cool-tinted). Sidebar text now shares
the same neutral temperature as the content text, so the sidebar no longer
reads cooler than the panels/cards. */
--color-nav-text: oklch(73.8% 0 0);
--color-nav-text: color-mix(in oklab, var(--md-stone-300) 20%, var(--md-stone-400)); /* MapleDeploy branding: stone-300/400 mix, L≈74.1 */
/* Raised from 58.97% (4.47:1 on panel, just under AA) to 61% (4.86:1) so the
11px sidebar section labels clear 4.5:1. */
--color-nav-muted: oklch(61% 0 0);
--color-nav-active: oklch(96.12% 0 0);
--color-nav-muted: color-mix(in oklab, var(--md-stone-400) 35%, var(--md-stone-500)); /* MapleDeploy branding: stone-400/500 mix, L≈60.8 */
--color-nav-active: var(--md-stone-100); /* MapleDeploy branding: stone-100 */
/* Tertiary text. Base token (59.31%) only clears 4.5:1 on app/panel; it
failed on surface/raised/selected (4.46/4.20/3.73). Raised to 64% so the
313 dark:text-fg-faint usages clear AA across the whole surface ladder,
while staying clearly dimmer than fg-dim (76.99%). */
--color-fg-faint: oklch(65% 0 0);
--color-fg-faint: color-mix(in oklab, var(--md-stone-400) 60%, var(--md-stone-500)); /* MapleDeploy branding: stone-400/500 mix, L≈64.7 */
/* Error text. The base error red (57.71%) failed on every dark surface
(4.10/3.75/3.53). Brightened for dark mode so text-error clears AA on the
card ladder while staying unmistakably red. */
--color-error: oklch(66% 0.2 27.33);
--color-error: oklch(66% 0.2 29.38); /* MapleDeploy branding: brand red hue */
}
/* Light-mode semantic text.
@ -1175,13 +1250,13 @@ .dark {
html:not(.dark) {
--color-warning: oklch(50% 0.105 80);
--color-success: oklch(48% 0.135 149.58);
--color-error: oklch(52% 0.2 27.33);
--color-error: oklch(52% 0.2 29.38); /* MapleDeploy branding: brand red hue */
}
/* Theme surfaces are derived from one brand color. Changing
--theme-base-color is enough to generate a complete dark surface ladder. */
html[data-theme="custom"] {
--theme-base-color: oklch(49.65% 0.2709 289.33);
--theme-base-color: #d52b1f; /* MapleDeploy branding: default custom-theme base -> red-600 */
--theme-bright-color: color-mix(in srgb, var(--theme-base-color) 85%, oklch(100% 0 0));
--theme-scrollbar-thumb: color-mix(in srgb, var(--theme-bright-color) 70%, var(--theme-accent-foreground));
--theme-border-color: color-mix(in oklab, var(--theme-base-color) 42%, oklch(44.19% 0.0146 285.79));
@ -1967,7 +2042,7 @@ .resource-heading-tabs-scroller {
.dark .resource-heading-tabs-scroller {
/* Matches dark:bg-white/[0.035] pill track + panel so fade blends */
--resource-heading-tabs-fade: color-mix(in srgb, var(--color-panel, #0a0a0a) 96.5%, white);
--resource-heading-tabs-fade: color-mix(in srgb, var(--color-panel, var(--md-stone-950)) 96.5%, white); /* MapleDeploy branding: stone remap */
}
.resource-heading-tabs-control {
@ -2032,8 +2107,8 @@ .resource-heading-tabs-control-icon {
}
.dark .resource-heading-tabs-control-icon {
color: var(--color-fg-dim, #a3a3a3);
background: var(--color-raised, #171717);
color: var(--color-fg-dim, #a8a29e); /* MapleDeploy branding: stone remap */
background: var(--color-raised, #191615); /* MapleDeploy branding: stone remap */
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.35),
0 0 0 1px rgba(255, 255, 255, 0.1);
@ -2047,8 +2122,8 @@ .resource-heading-tabs-control:focus-visible .resource-heading-tabs-control-icon
.dark .resource-heading-tabs-control:hover .resource-heading-tabs-control-icon,
.dark .resource-heading-tabs-control:focus-visible .resource-heading-tabs-control-icon {
color: var(--color-fg, #fafafa);
background: var(--color-raised, #171717);
color: var(--color-fg, #fafaf9); /* MapleDeploy branding: stone remap */
background: var(--color-raised, #191615); /* MapleDeploy branding: stone remap */
}
.resource-heading-tabs-control:focus {
@ -2057,7 +2132,7 @@ .resource-heading-tabs-control:focus {
.resource-heading-tabs-control:focus-visible .resource-heading-tabs-control-icon {
box-shadow:
0 0 0 2px color-mix(in srgb, var(--color-coollabs, #6b16ed) 45%, transparent),
0 0 0 2px color-mix(in srgb, var(--color-coollabs, #d52b1f) 45%, transparent), /* MapleDeploy branding */
0 0 0 1px rgba(0, 0, 0, 0.08);
}
@ -2434,7 +2509,7 @@ .dark .searchable-listbox-search-input {
}
.searchable-listbox-search-input::placeholder {
color: var(--color-fg-faint, #737373);
color: var(--color-fg-faint, #78716c); /* MapleDeploy branding: stone remap */
}
.searchable-listbox-search-input:focus {
@ -2467,7 +2542,7 @@ .searchable-listbox-empty {
padding: 0.75rem 0.5rem;
text-align: center;
font-size: 0.75rem;
color: var(--color-fg-dim, #737373);
color: var(--color-fg-dim, #78716c); /* MapleDeploy branding: stone remap */
}
/* Flush layer-card body (tables and other full-bleed content) */
@ -3415,7 +3490,7 @@ .logs-viewer {
.dark .logs-viewer {
background: var(--color-log);
color: #f5f5f5;
color: #f5f5f4; /* MapleDeploy branding: stone remap (stone-100) */
}
.logs-viewer-toolbar {
@ -3497,7 +3572,7 @@ .logs-viewer-lines-label {
}
.dark .logs-viewer-lines-label {
color: var(--color-fg-faint, #6e6e74);
color: var(--color-fg-faint, #756d67); /* MapleDeploy branding: stone remap */
}
.logs-viewer-lines-input {
@ -3534,7 +3609,7 @@ .logs-viewer-status-badge {
.dark .logs-viewer-status-badge {
border-color: rgba(255, 255, 255, 0.1) !important;
background: rgba(255, 255, 255, 0.05) !important;
color: #d4d4d4 !important;
color: #d6d3d1 !important; /* MapleDeploy branding: stone remap (stone-300) */
}
/* Section header status (proxy/sentinel) stays inline with title on mobile */
@ -3659,12 +3734,12 @@ .logs-viewer-btn:hover {
}
.dark .logs-viewer-btn {
color: #a3a3a3;
color: #a8a29e; /* MapleDeploy branding: stone remap (stone-400) */
}
.dark .logs-viewer-btn:hover {
background: rgba(255, 255, 255, 0.06);
color: #f5f5f5;
color: #f5f5f4; /* MapleDeploy branding: stone remap (stone-100) */
}
.logs-viewer-btn-active {

View file

@ -70,18 +70,12 @@ @font-face {
src: url('../fonts/inter-v13-cyrillic_cyrillic-ext_greek_greek-ext_latin_latin-ext_vietnamese-regular.woff2') format('woff2');
}
/* MapleDeploy branding: Overlock for headings */
@font-face {
font-display: swap;
font-family: 'Geist Mono';
font-family: 'Overlock';
font-style: normal;
font-weight: 100 900;
src: url('../fonts/geist-mono-variable.woff2') format('woff2');
}
@font-face {
font-display: swap;
font-family: 'Geist Sans';
font-style: normal;
font-weight: 100 900;
src: url('../fonts/geist-sans-variable.woff2') format('woff2');
font-weight: 900;
src: url('../fonts/overlock-v19-latin-900.woff2') format('woff2'),
url('../fonts/overlock-v19-latin-900.ttf') format('truetype');
}

View file

@ -36,15 +36,15 @@ @utility input-sticky {
box-shadow: inset 4px 0 0 transparent, inset 0 0 0 1px #e5e5e5;
&:where(.dark, .dark *) {
box-shadow: inset 4px 0 0 transparent, inset 0 0 0 1px #242424;
box-shadow: inset 4px 0 0 transparent, inset 0 0 0 1px #292524; /* MapleDeploy branding: stone-800 (was #242424) */
}
&:focus-visible {
box-shadow: inset 4px 0 0 #6b16ed, inset 0 0 0 1px #e5e5e5;
box-shadow: inset 4px 0 0 #d52b1f, inset 0 0 0 1px #e5e5e5;
}
&:where(.dark, .dark *):focus-visible {
box-shadow: inset 4px 0 0 #fcd452, inset 0 0 0 1px #242424;
box-shadow: inset 4px 0 0 #fde047, inset 0 0 0 1px #292524; /* MapleDeploy branding: stone-800 (was #242424) */
}
}
@ -182,7 +182,7 @@ @utility tag {
}
@utility add-tag {
@apply flex items-center px-2 text-xs cursor-pointer dark:text-neutral-500/20 text-neutral-500 group-hover:text-neutral-700 dark:group-hover:text-white dark:hover:bg-coolgray-300 hover:bg-neutral-200;
@apply flex items-center px-2 text-xs cursor-pointer dark:text-neutral-500 text-neutral-500 group-hover:text-neutral-700 dark:group-hover:text-white dark:hover:bg-coolgray-300 hover:bg-neutral-200;
}
@utility user-menu-item {
@ -279,7 +279,8 @@ @utility icon {
}
@utility scrollbar {
@apply scrollbar-thumb-coollabs-100 scrollbar-track-neutral-200 dark:scrollbar-thumb-coollabs-100 dark:scrollbar-track-coolgray-200 scrollbar-thin;
/* MapleDeploy branding: yellow scrollbar thumb instead of Coolify brand color */
@apply scrollbar-thumb-warning scrollbar-track-neutral-200 dark:scrollbar-thumb-warning dark:scrollbar-track-coolgray-200 scrollbar-thin;
}
@utility main {
@ -339,7 +340,8 @@ @utility description {
}
@utility bg-coollabs-gradient {
@apply from-purple-500 via-pink-500 to-red-500 bg-linear-to-r;
/* MapleDeploy branding */
@apply from-red-700 via-red-500 to-red-400 bg-linear-to-r;
}
@utility text-helper {
@ -411,7 +413,7 @@ @utility log-warning {
}
@utility log-debug {
@apply bg-purple-500/10 dark:bg-purple-500/15;
@apply bg-stone-500/10 dark:bg-stone-500/15;
}
@utility log-info {

Binary file not shown.

Binary file not shown.

View file

@ -43,17 +43,17 @@ function createApplicationTerminalTheme(accent, colors = {}) {
return {
...baseApplicationTerminalTheme,
cursor: accent,
cursorAccent: '#101012',
cursorAccent: '#0c0a09', // MapleDeploy branding: stone-950 (was #101012)
selectionBackground: `${accent}66`,
...colors,
};
}
function customThemeAccent() {
const color = localStorage.getItem('themeColor') || '#6b16ed';
const color = localStorage.getItem('themeColor') || '#d52b1f'; // MapleDeploy branding: custom theme default color
if (!/^#[0-9a-f]{6}$/i.test(color)) {
return '#7c3aed';
return '#d52b1f'; // MapleDeploy branding
}
const channels = color.match(/[a-f\d]{2}/gi).map((channel) => (
@ -69,7 +69,7 @@ function createSystemTerminalTheme() {
}
if (document.documentElement.classList.contains('dark')) {
return createApplicationTerminalTheme('#8C8E9C');
return createApplicationTerminalTheme('#958D89'); // MapleDeploy branding: stone remap (was #8C8E9C)
}
return createApplicationTerminalTheme('#52525b', {
@ -143,7 +143,7 @@ const applicationTerminalThemes = {
cyan: '#51d5d5',
brightMagenta: '#ffa1d4',
}),
'shadows-transparent': createApplicationTerminalTheme('#8C8E9C'),
'shadows-transparent': createApplicationTerminalTheme('#958D89'), // MapleDeploy branding: stone remap (was #8C8E9C)
};
function logTerminal(level, message, ...context) {

View file

@ -93,7 +93,7 @@ class="auth-tooltip max-w-xs whitespace-normal">
<x-slot:footer>
@if ($is_registration_enabled)
<span>New to Coolify?</span>
<span>New to MapleDeploy?</span> {{-- MapleDeploy branding --}}
<a href="/register" class="auth-text-link">{{ __('auth.register_now') }}</a>
@else
<span>{{ __('auth.registration_disabled') }}</span>

View file

@ -99,10 +99,11 @@
{{-- Unified Input Container with Tags Inside --}}
<div @click="$refs.searchInput.focus()" x-data="{ focused: false }" @focusin="focused = true" @focusout="focused = false"
class="flex flex-wrap gap-1.5 max-h-40 overflow-y-auto scrollbar py-1.5 px-2 w-full text-sm rounded-sm border-0 bg-white dark:bg-coolgray-100 cursor-text px-1 text-black dark:text-white"
{{-- MapleDeploy branding: red accent instead of Coolify purple; dark border stone-800 #292524 (was #242424) --}}
:style="(() => {
const isDark = document.documentElement.classList.contains('dark');
const accent = isDark ? '#fcd452' : '#6b16ed';
const border = isDark ? '#242424' : '#e5e5e5';
const accent = isDark ? '#fde047' : '#d52b1f';
const border = isDark ? '#292524' : '#e7e5e4';
return focused
? 'box-shadow: inset 4px 0 0 ' + accent + ', inset 0 0 0 2px ' + border + ';'
: 'box-shadow: inset 4px 0 0 transparent, inset 0 0 0 2px ' + border + ';';
@ -110,7 +111,7 @@ class="flex flex-wrap gap-1.5 max-h-40 overflow-y-auto scrollbar py-1.5 px-2 w-
:class="{
'opacity-50': {{ $disabled ? 'true' : 'false' }}
}" wire:loading.class="opacity-50"
wire:dirty.class="[box-shadow:inset_4px_0_0_#6b16ed,inset_0_0_0_2px_#e5e5e5] dark:[box-shadow:inset_4px_0_0_#fcd452,inset_0_0_0_2px_#242424]">
wire:dirty.class="[box-shadow:inset_4px_0_0_#d52b1f,inset_0_0_0_2px_#e7e5e4] dark:[box-shadow:inset_4px_0_0_#fde047,inset_0_0_0_2px_#292524]">
{{-- Selected Tags Inside Input --}}
<template x-for="value in selected" :key="value">
@ -240,17 +241,18 @@ class="pointer-events-none absolute inset-0 rounded-[5px] border border-neutral-
{{-- Input Container --}}
<div @click="openDropdown()" x-data="{ focused: false }" @focusin="focused = true" @focusout="focused = false"
class="flex items-center gap-2 py-1.5 w-full text-sm rounded-sm border-0 bg-white dark:bg-coolgray-100 cursor-text text-black dark:text-white"
{{-- MapleDeploy branding: red accent instead of Coolify purple; dark border stone-800 #292524 (was #242424) --}}
:style="(() => {
const isDark = document.documentElement.classList.contains('dark');
const accent = isDark ? '#fcd452' : '#6b16ed';
const border = isDark ? '#242424' : '#e5e5e5';
const accent = isDark ? '#fde047' : '#d52b1f';
const border = isDark ? '#292524' : '#e7e5e4';
return focused
? 'box-shadow: inset 4px 0 0 ' + accent + ', inset 0 0 0 2px ' + border + ';'
: 'box-shadow: inset 4px 0 0 transparent, inset 0 0 0 2px ' + border + ';';
})()"
:class="{
'opacity-50': {{ $disabled ? 'true' : 'false' }}
}" wire:loading.class="opacity-50" wire:dirty.class="[box-shadow:inset_4px_0_0_#6b16ed,inset_0_0_0_2px_#e5e5e5] dark:[box-shadow:inset_4px_0_0_#fcd452,inset_0_0_0_2px_#242424]">
}" wire:loading.class="opacity-50" wire:dirty.class="[box-shadow:inset_4px_0_0_#d52b1f,inset_0_0_0_2px_#e7e5e4] dark:[box-shadow:inset_4px_0_0_#fde047,inset_0_0_0_2px_#292524]">
{{-- Display Selected Value or Search Input --}}
<div class="flex-1 flex items-center min-w-0 px-1">

View file

@ -211,7 +211,7 @@
@readonly($readonly)
@if ($modelBinding !== 'null')
wire:model="{{ $modelBinding }}"
wire:dirty.class="[box-shadow:inset_4px_0_0_#d52b1f,inset_0_0_0_2px_#e5e5e5] dark:[box-shadow:inset_4px_0_0_#fde047,inset_0_0_0_2px_#242424]"
wire:dirty.class="[box-shadow:inset_4px_0_0_#d52b1f,inset_0_0_0_2px_#e7e5e4] dark:[box-shadow:inset_4px_0_0_#fde047,inset_0_0_0_2px_#292524]"
@endif
wire:loading.attr="disabled"
@disabled($disabled)

View file

@ -28,16 +28,6 @@
</div>
@elseif ($type === 'password')
<div class="relative" x-data="{ type: 'password' }" @success.window="type = 'password'">
<input autocomplete="{{ $autocomplete }}" value="{{ $value }}"
x-bind:type="type"
x-bind:class="{ 'truncate': type === 'text' && ! $el.disabled }"
{{ $attributes->merge(['class' => $defaultClass]) }} @required($required)
@if ($modelBinding !== 'null') wire:model={{ $modelBinding }} wire:dirty.class="[box-shadow:inset_4px_0_0_#6b16ed,inset_0_0_0_2px_#e5e5e5] dark:[box-shadow:inset_4px_0_0_#fcd452,inset_0_0_0_2px_#242424]" @endif
wire:loading.attr="disabled"
@readonly($readonly) @disabled($disabled) id="{{ $htmlId }}"
name="{{ $name }}" placeholder="{{ $attributes->get('placeholder') }}"
aria-placeholder="{{ $attributes->get('placeholder') }}"
@if ($autofocus) x-ref="autofocusInput" @endif>
@if ($allowToPeak)
<button type="button" x-on:click="type = type === 'password' ? 'text' : 'password'"
class="password-toggle flex absolute inset-y-0 right-0 z-10 items-center pr-2 cursor-pointer text-neutral-500 hover:text-black dark:text-neutral-400 dark:hover:text-white"
@ -48,12 +38,22 @@ class="password-toggle flex absolute inset-y-0 right-0 z-10 items-center pr-2 cu
<x-reicon name="eye-off2" x-cloak x-show="type === 'text'" class="size-[18px]" />
</button>
@endif
<input autocomplete="{{ $autocomplete }}" value="{{ $value }}"
x-bind:type="type"
x-bind:class="{ 'truncate': type === 'text' && ! $el.disabled }"
{{ $attributes->merge(['class' => $defaultClass]) }} @required($required)
@if ($modelBinding !== 'null') wire:model={{ $modelBinding }} wire:dirty.class="[box-shadow:inset_4px_0_0_#d52b1f,inset_0_0_0_2px_#e7e5e4] dark:[box-shadow:inset_4px_0_0_#fde047,inset_0_0_0_2px_#292524]" @endif
wire:loading.attr="disabled"
@readonly($readonly) @disabled($disabled) id="{{ $htmlId }}"
name="{{ $name }}" placeholder="{{ $attributes->get('placeholder') }}"
aria-placeholder="{{ $attributes->get('placeholder') }}"
@if ($autofocus) x-ref="autofocusInput" @endif>
</div>
@else
<input autocomplete="{{ $autocomplete }}" @if ($value) value="{{ $value }}" @endif
{{ $attributes->merge(['class' => $defaultClass]) }} @required($required) @readonly($readonly)
@if ($modelBinding !== 'null') wire:model={{ $modelBinding }} wire:dirty.class="[box-shadow:inset_4px_0_0_#6b16ed,inset_0_0_0_2px_#e5e5e5] dark:[box-shadow:inset_4px_0_0_#fcd452,inset_0_0_0_2px_#242424]" @endif
@if ($modelBinding !== 'null') wire:model={{ $modelBinding }} wire:dirty.class="[box-shadow:inset_4px_0_0_#d52b1f,inset_0_0_0_2px_#e7e5e4] dark:[box-shadow:inset_4px_0_0_#fde047,inset_0_0_0_2px_#292524]" @endif
wire:loading.attr="disabled"
type="{{ $type }}" @disabled($disabled) min="{{ $attributes->get('min') }}"
max="{{ $attributes->get('max') }}" minlength="{{ $attributes->get('minlength') }}"

View file

@ -46,10 +46,11 @@
inherit: true,
rules: [],
colors: {
'editor.background': '#0b0b0c',
'editorGutter.background': '#0b0b0c',
'editorStickyScroll.background': '#0b0b0c',
'minimap.background': '#0b0b0c',
// MapleDeploy branding: stone-950 (was #0b0b0c)
'editor.background': '#0c0a09',
'editorGutter.background': '#0c0a09',
'editorStickyScroll.background': '#0c0a09',
'minimap.background': '#0c0a09',
'scrollbarSlider.background': '#ffffff1a',
'scrollbarSlider.hoverBackground': '#ffffff2e',
'scrollbarSlider.activeBackground': '#ffffff40',

View file

@ -14,7 +14,7 @@ class="mb-0! flex items-center gap-1 text-sm font-medium leading-4 {{ $disabled
@endif
<select {{ $attributes->merge(['class' => $defaultClass]) }} @disabled($disabled) @required($required)
wire:loading.attr="disabled" name={{ $modelBinding }} id="{{ $htmlId }}"
@if ($attributes->whereStartsWith('wire:model')->first()) {{ $attributes->whereStartsWith('wire:model')->first() }} wire:dirty.class="[box-shadow:inset_4px_0_0_#6b16ed,inset_0_0_0_2px_#e5e5e5] dark:[box-shadow:inset_4px_0_0_#fcd452,inset_0_0_0_2px_#242424]" @else wire:model={{ $modelBinding }} wire:dirty.class="[box-shadow:inset_4px_0_0_#6b16ed,inset_0_0_0_2px_#e5e5e5] dark:[box-shadow:inset_4px_0_0_#fcd452,inset_0_0_0_2px_#242424]" @endif>
@if ($attributes->whereStartsWith('wire:model')->first()) {{ $attributes->whereStartsWith('wire:model')->first() }} wire:dirty.class="[box-shadow:inset_4px_0_0_#d52b1f,inset_0_0_0_2px_#e7e5e4] dark:[box-shadow:inset_4px_0_0_#fde047,inset_0_0_0_2px_#292524]" @else wire:model={{ $modelBinding }} wire:dirty.class="[box-shadow:inset_4px_0_0_#d52b1f,inset_0_0_0_2px_#e7e5e4] dark:[box-shadow:inset_4px_0_0_#fde047,inset_0_0_0_2px_#292524]" @endif>
{{ $slot }}
</select>
@error($modelBinding)

View file

@ -36,21 +36,6 @@ function handleKeydown(e) {
@else
@if ($type === 'password')
<div class="relative" x-data="{ type: 'password' }" @success.window="type = 'password'">
<input x-cloak x-show="type === 'password'" value="{{ $value }}"
{{ $attributes->merge(['class' => $defaultClassInput]) }} @required($required)
@if ($modelBinding !== 'null') wire:model={{ $modelBinding }} wire:dirty.class="[box-shadow:inset_4px_0_0_#6b16ed,inset_0_0_0_2px_#e5e5e5] dark:[box-shadow:inset_4px_0_0_#fcd452,inset_0_0_0_2px_#242424]" @endif
wire:loading.attr="disabled"
type="{{ $type }}" @readonly($readonly) @disabled($disabled) id="{{ $htmlId }}"
name="{{ $name }}" placeholder="{{ $attributes->get('placeholder') }}"
aria-placeholder="{{ $attributes->get('placeholder') }}">
<textarea minlength="{{ $minlength }}" maxlength="{{ $maxlength }}" x-cloak x-show="type !== 'password'"
placeholder="{{ $placeholder }}" {{ $attributes->merge(['class' => $defaultClass]) }}
@if ($realtimeValidation) wire:model.debounce.200ms="{{ $modelBinding }}" wire:dirty.class="[box-shadow:inset_4px_0_0_#6b16ed,inset_0_0_0_2px_#e5e5e5] dark:[box-shadow:inset_4px_0_0_#fcd452,inset_0_0_0_2px_#242424]"
@else
wire:model={{ $value ?? $modelBinding }} wire:dirty.class="[box-shadow:inset_4px_0_0_#6b16ed,inset_0_0_0_2px_#e5e5e5] dark:[box-shadow:inset_4px_0_0_#fcd452,inset_0_0_0_2px_#242424]" @endif
@disabled($disabled) @readonly($readonly) @required($required) id="{{ $htmlId }}"
name="{{ $name }}" name={{ $modelBinding }}
@if ($autofocus) x-ref="autofocusInput" @endif></textarea>
@if ($allowToPeak)
<button type="button" x-on:click="type = type === 'password' ? 'text' : 'password'"
class="absolute inset-y-0 right-0 flex items-center h-6 pt-2 pr-2 cursor-pointer dark:hover:text-white"
@ -71,15 +56,30 @@ class="absolute inset-y-0 right-0 flex items-center h-6 pt-2 pr-2 cursor-pointer
</svg>
</button>
@endif
<input x-cloak x-show="type === 'password'" value="{{ $value }}"
{{ $attributes->merge(['class' => $defaultClassInput]) }} @required($required)
@if ($modelBinding !== 'null') wire:model={{ $modelBinding }} wire:dirty.class="[box-shadow:inset_4px_0_0_#d52b1f,inset_0_0_0_2px_#e7e5e4] dark:[box-shadow:inset_4px_0_0_#fde047,inset_0_0_0_2px_#292524]" @endif
wire:loading.attr="disabled"
type="{{ $type }}" @readonly($readonly) @disabled($disabled) id="{{ $htmlId }}"
name="{{ $name }}" placeholder="{{ $attributes->get('placeholder') }}"
aria-placeholder="{{ $attributes->get('placeholder') }}">
<textarea minlength="{{ $minlength }}" maxlength="{{ $maxlength }}" x-cloak x-show="type !== 'password'"
placeholder="{{ $placeholder }}" {{ $attributes->merge(['class' => $defaultClass]) }}
@if ($realtimeValidation) wire:model.debounce.200ms="{{ $modelBinding }}" wire:dirty.class="[box-shadow:inset_4px_0_0_#d52b1f,inset_0_0_0_2px_#e7e5e4] dark:[box-shadow:inset_4px_0_0_#fde047,inset_0_0_0_2px_#292524]"
@else
wire:model={{ $value ?? $modelBinding }} wire:dirty.class="[box-shadow:inset_4px_0_0_#d52b1f,inset_0_0_0_2px_#e7e5e4] dark:[box-shadow:inset_4px_0_0_#fde047,inset_0_0_0_2px_#292524]" @endif
@disabled($disabled) @readonly($readonly) @required($required) id="{{ $htmlId }}"
name="{{ $name }}" name={{ $modelBinding }}
@if ($autofocus) x-ref="autofocusInput" @endif></textarea>
</div>
@else
<textarea minlength="{{ $minlength }}" maxlength="{{ $maxlength }}"
{{ $allowTab ? '@keydown.tab=handleKeydown' : '' }} placeholder="{{ $placeholder }}"
{{ !$spellcheck ? 'spellcheck=false' : '' }} {{ $attributes->merge(['class' => $defaultClass]) }}
@if ($realtimeValidation) wire:model.debounce.200ms="{{ $modelBinding }}" wire:dirty.class="[box-shadow:inset_4px_0_0_#6b16ed,inset_0_0_0_2px_#e5e5e5] dark:[box-shadow:inset_4px_0_0_#fcd452,inset_0_0_0_2px_#242424]"
@if ($realtimeValidation) wire:model.debounce.200ms="{{ $modelBinding }}" wire:dirty.class="[box-shadow:inset_4px_0_0_#d52b1f,inset_0_0_0_2px_#e7e5e4] dark:[box-shadow:inset_4px_0_0_#fde047,inset_0_0_0_2px_#292524]"
@else
wire:model={{ $value ?? $modelBinding }} wire:dirty.class="[box-shadow:inset_4px_0_0_#6b16ed,inset_0_0_0_2px_#e5e5e5] dark:[box-shadow:inset_4px_0_0_#fcd452,inset_0_0_0_2px_#242424]" @endif
wire:model={{ $value ?? $modelBinding }} wire:dirty.class="[box-shadow:inset_4px_0_0_#d52b1f,inset_0_0_0_2px_#e7e5e4] dark:[box-shadow:inset_4px_0_0_#fde047,inset_0_0_0_2px_#292524]" @endif
@disabled($disabled) @readonly($readonly) @required($required) id="{{ $htmlId }}"
name="{{ $name }}" name={{ $modelBinding }}
@if ($autofocus) x-ref="autofocusInput" @endif></textarea>

View file

@ -47,7 +47,7 @@
document.documentElement.classList.remove('dark');
}
document.documentElement.dataset.theme = userSettings === 'custom' ? 'custom' : (isDark ? 'dark' : 'light');
document.querySelector('meta[name=theme-color]')?.setAttribute('content', isDark ? '#101010' : '#ffffff');
document.querySelector('meta[name=theme-color]')?.setAttribute('content', isDark ? '#0c0a09' : '#ffffff'); // MapleDeploy branding: stone-950 (was #101010)
}
}">
{{-- Search is only useful when workspace resources are available --}}

View file

@ -14,7 +14,7 @@ class="terminal-theme-trigger flex h-8 items-center gap-2 rounded-md px-2.5 text
</button>
<div x-cloak x-show="themeOpen" x-transition.origin.top.right
class="console-theme-selector absolute top-11 right-0 z-50 max-h-80 w-56 overflow-y-auto rounded-lg border border-neutral-200 bg-white p-1 shadow-[var(--shadow-dropdown)] dark:border-white/[0.1] dark:bg-[#111113]">
class="console-theme-selector absolute top-11 right-0 z-50 max-h-80 w-56 overflow-y-auto rounded-lg border border-neutral-200 bg-white p-1 shadow-[var(--shadow-dropdown)] dark:border-white/[0.1] dark:bg-stone-950"> {{-- MapleDeploy branding: stone-950 (was #111113) --}}
@foreach ($themes as $theme)
<button type="button"
class="flex h-8 w-full items-center gap-2 rounded-md px-2 text-left text-[11px] text-neutral-600 transition-colors hover:bg-neutral-100 hover:text-neutral-950 dark:text-white/65 dark:hover:bg-white/[0.07] dark:hover:text-white"

View file

@ -76,7 +76,7 @@ class="size-3.5 text-coollabs dark:text-warning" viewBox="0 0 12 12" fill="none"
@foreach ([
['value' => 'light', 'label' => 'Light', 'description' => 'Bright surfaces and dark text.', 'preview' => 'bg-white'],
['value' => 'system', 'label' => 'System', 'description' => 'Follow your operating system.', 'preview' => 'bg-gradient-to-r from-white via-neutral-400 to-[#050505]'],
['value' => 'dark', 'label' => 'Dark', 'description' => 'Dark surfaces and soft contrast.', 'preview' => 'bg-[#181818]'],
['value' => 'dark', 'label' => 'Dark', 'description' => 'Dark surfaces and soft contrast.', 'preview' => 'bg-stone-900'],
['value' => 'custom', 'label' => 'Custom', 'description' => 'Tint light or dark surfaces with any color.', 'preview' => ''],
] as $option)
@if ($option['value'] === 'custom')
@ -160,7 +160,7 @@ class="group overflow-hidden rounded-[10px] border border-neutral-200 bg-white t
? 'ring-1 ring-coollabs/30 border-coollabs/40 dark:ring-warning/30 dark:border-warning/40'
: ''">
<div class="flex h-20 items-center border-b border-neutral-200 bg-neutral-50 px-4 dark:border-white/[0.07] dark:bg-black/15">
<div class="flex h-11 w-full gap-1.5 rounded-md border border-neutral-300 bg-white p-1.5 dark:border-white/15 dark:bg-[#181818]">
<div class="flex h-11 w-full gap-1.5 rounded-md border border-neutral-300 bg-white p-1.5 dark:border-white/15 dark:bg-stone-900">
<div class="w-3 shrink-0 rounded-sm bg-neutral-200 dark:bg-white/10"></div>
<div @class([
'h-full rounded-sm bg-neutral-200 dark:bg-white/10',

View file

@ -41,7 +41,7 @@
document.documentElement.dataset.theme = theme === 'custom' ? 'custom' : (isDark ? 'dark' : 'light');
document.documentElement.style.setProperty('--theme-base-color', window.hexToOklch(themeColor));
document.documentElement.style.setProperty('--theme-accent-foreground', window.themeAccentForeground(themeColor));
document.querySelector('meta[name=theme-color]')?.setAttribute('content', isDark ? '#101010' : '#ffffff');
document.querySelector('meta[name=theme-color]')?.setAttribute('content', isDark ? '#0c0a09' : '#ffffff'); // MapleDeploy branding: stone-950 (was #101010)
};
// Single source for the theme controls Alpine state, shared by the
// Appearance page and the profile dropdown via x-data="themeControls()".
@ -110,7 +110,7 @@
document.documentElement.dataset.theme = this.theme === 'custom' ? 'custom' : (isDark ? 'dark' : 'light');
document.documentElement.style.setProperty('--theme-base-color', window.hexToOklch(this.themeColor));
document.documentElement.style.setProperty('--theme-accent-foreground', window.themeAccentForeground(this.themeColor));
document.querySelector('meta[name=theme-color]')?.setAttribute('content', isDark ? '#12100e' : '#ffffff'); // MapleDeploy branding: stone remap (was #101010)
document.querySelector('meta[name=theme-color]')?.setAttribute('content', isDark ? '#0c0a09' : '#ffffff'); // MapleDeploy branding: stone-950 (was #101010)
},
});
@ -123,7 +123,8 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<meta name="theme-color" content="#101010" id="theme-color-meta" />
{{-- MapleDeploy branding: stone-950 (was #101010) --}}
<meta name="theme-color" content="#0c0a09" id="theme-color-meta" />
<meta name="color-scheme" content="dark light" />
{{-- MapleDeploy branding --}}
<meta name="Description" content="MapleDeploy: Managed Coolify hosting on Canadian infrastructure" />
@ -159,7 +160,7 @@
// Update theme-color meta tag (non-critical, can run async)
const t = localStorage.theme || 'dark';
const isDark = t === 'dark' || t === 'custom' || (t === 'system' && matchMedia('(prefers-color-scheme: dark)').matches);
document.getElementById('theme-color-meta')?.setAttribute('content', isDark ? '#101010' : '#ffffff');
document.getElementById('theme-color-meta')?.setAttribute('content', isDark ? '#0c0a09' : '#ffffff'); // MapleDeploy branding: stone-950 (was #101010)
</script>
<style>
[x-cloak] {
@ -251,7 +252,7 @@
let ramColor = '#00ced1'
let textColor = '#ffffff'
let gridColor = '#44403c'
let editorBackground = '#181818'
let editorBackground = '#1a1716' // MapleDeploy branding: stone remap (was #181818)
let editorTheme = 'blackboard'
function checkTheme() {
@ -264,7 +265,7 @@ function checkTheme() {
ramColor = '#00ced1'
textColor = '#ffffff'
gridColor = '#44403c'
editorBackground = '#181818'
editorBackground = '#1a1716' // MapleDeploy branding: stone remap (was #181818)
editorTheme = 'blackboard'
} else {
cpuColor = '#1e90ff'

View file

@ -1,6 +1,6 @@
<div>
@unless ($embedded)
<x-slot:title>{{ data_get_str($application, 'name')->limit(10) }} > Deployments | Coolify</x-slot>
<x-slot:title>{{ data_get_str($application, 'name')->limit(10) }} > Deployments | MapleDeploy</x-slot>
<livewire:project.shared.configuration-checker :resource="$application" />
<livewire:project.application.heading :application="$application" wire:key="application-heading-deployment-index" />
@endunless

View file

@ -367,7 +367,7 @@ class="logs-viewer-btn">
x-transition:leave="transition ease-in duration-75"
x-transition:leave-start="transform opacity-100 scale-100"
x-transition:leave-end="transform opacity-0 scale-95"
class="absolute right-0 z-50 mt-2 w-max origin-top-right rounded-lg border border-neutral-200 bg-white p-1 shadow-dropdown focus:outline-none dark:border-white/[0.1] dark:bg-[#181818]">
class="absolute right-0 z-50 mt-2 w-max origin-top-right rounded-lg border border-neutral-200 bg-white p-1 shadow-dropdown focus:outline-none dark:border-white/[0.1] dark:bg-stone-900"> {{-- MapleDeploy branding: stone-900 (was #181818) --}}
<div>
<button x-on:click="downloadLogs(); downloadMenuOpen = false"
class="listbox-option text-neutral-700! hover:bg-neutral-100! dark:text-neutral-200! dark:hover:bg-white/[0.07]!">

View file

@ -7,7 +7,8 @@ class="flex min-h-0 flex-col gap-3">
Volume names are prefixed with the service UUID when you save to prevent collisions.
</x-callout>
<div class="compose-editor-container min-h-[24rem] overflow-hidden rounded-lg border border-neutral-200 bg-white dark:border-white/[0.10] dark:bg-[#0b0b0c]"
{{-- MapleDeploy branding: stone remap (was #0b0b0c, matches Monaco editor.background) --}}
<div class="compose-editor-container min-h-[24rem] overflow-hidden rounded-lg border border-neutral-200 bg-white dark:border-white/[0.10] dark:bg-[#0d0b0a]"
style="--editor-height: clamp(24rem, calc(100dvh - 25rem), 48rem)">
<div x-cloak x-show="raw" class="font-mono">
<div x-cloak x-show="showNormalTextarea">

View file

@ -18,7 +18,8 @@
$consoleUnavailable = ($type === 'server' && (! $servers->first()->isTerminalEnabled() || ! $servers->first()->isFunctional()))
|| ($type !== 'server' && $containersLoaded && $containers->isEmpty());
$consoleThemes = [
['key' => 'system', 'name' => 'System', 'background' => 'linear-gradient(135deg, #ffffff 0 50%, #121214 50% 100%)', 'accent' => '#8C8E9C'],
// MapleDeploy branding: stone remap (system/blur-black swatches: was #121214 / #8C8E9C)
['key' => 'system', 'name' => 'System', 'background' => 'linear-gradient(135deg, #ffffff 0 50%, color-mix(in oklab, #1c1917 50%, #0c0a09) 50% 100%)', 'accent' => '#78716c'],
['key' => 'shadows-midnight', 'name' => 'Midnight', 'background' => 'linear-gradient(135deg, #2a3b4c, rgba(42, 59, 76, 0.4))', 'accent' => '#6d7a7c'],
['key' => 'shadows-golden-hour', 'name' => 'Golden Hour', 'background' => 'linear-gradient(135deg, #d58a42, rgba(213, 138, 66, 0.4))', 'accent' => '#bf8c3c'],
['key' => 'shadows-cosmic-purple', 'name' => 'Cosmic Purple', 'background' => 'linear-gradient(135deg, #5d3e66, rgba(93, 62, 102, 0.4))', 'accent' => '#A76DBE'],
@ -28,7 +29,7 @@
['key' => 'shadows-golden-nebula', 'name' => 'Golden Nebula', 'background' => 'linear-gradient(135deg, #ffd700, #ff6347, #d4a20e, #ffcc00, #1f3d6f)', 'accent' => '#d4a20e'],
['key' => 'shadows-cosmic-lagoon', 'name' => 'Cosmic Lagoon', 'background' => 'linear-gradient(135deg, #1d2b64, #2f4f96, #00b5b8, #9c27b0, #8e24aa)', 'accent' => '#00b5b8'],
['key' => 'shadows-neon-nebula', 'name' => 'Neon Nebula', 'background' => 'linear-gradient(135deg, #00d9d9, #ff55aa, #1e1e2f, #2f3b57, #ff99ff)', 'accent' => '#ff55aa'],
['key' => 'shadows-transparent', 'name' => 'Blur Black', 'background' => 'rgba(0, 0, 0, 0.7)', 'accent' => '#8C8E9C'],
['key' => 'shadows-transparent', 'name' => 'Blur Black', 'background' => 'rgba(0, 0, 0, 0.7)', 'accent' => '#78716c'],
];
$consoleThemeKeys = collect($consoleThemes)->pluck('key')->values();
$consoleThemeNames = collect($consoleThemes)->pluck('name', 'key');

View file

@ -106,7 +106,7 @@
enabled: false,
},
grid: {
borderColor: 'rgba(128, 128, 128, 0.14)',
borderColor: gridColor, // MapleDeploy branding
strokeDashArray: 4,
},
legend: {
@ -175,6 +175,9 @@
name: 'CPU',
data: chartData[0].seriesData,
}],
grid: {
borderColor: gridColor, // MapleDeploy branding
},
xaxis: {
type: 'datetime',
labels: {
@ -257,7 +260,7 @@
enabled: false,
},
grid: {
borderColor: 'rgba(128, 128, 128, 0.14)',
borderColor: gridColor, // MapleDeploy branding
strokeDashArray: 4,
},
legend: {
@ -322,6 +325,9 @@
name: 'Memory',
data: chartData[0].seriesData,
}],
grid: {
borderColor: gridColor, // MapleDeploy branding
},
xaxis: {
type: 'datetime',
labels: {

View file

@ -1,6 +1,6 @@
<div>
<x-slot:title>
{{ data_get_str($server, 'name')->limit(10) }} > Metrics | Coolify
{{ data_get_str($server, 'name')->limit(10) }} > Metrics | MapleDeploy
</x-slot>
<livewire:server.navbar :server="$server" />
@ -109,7 +109,7 @@ class="server-settings-workspace application-settings-workspace mt-4 grid w-full
enabled: false,
},
grid: {
borderColor: 'rgba(128, 128, 128, 0.14)',
borderColor: gridColor, // MapleDeploy branding
strokeDashArray: 4,
},
legend: {
@ -189,6 +189,9 @@ class="server-settings-workspace application-settings-workspace mt-4 grid w-full
name: 'CPU',
data: chartData[0].seriesData,
}],
grid: {
borderColor: gridColor, // MapleDeploy branding
},
xaxis: {
type: 'datetime',
labels: {
@ -227,6 +230,9 @@ class="server-settings-workspace application-settings-workspace mt-4 grid w-full
name: 'Memory',
data: chartData[0].seriesData,
}],
grid: {
borderColor: gridColor, // MapleDeploy branding
},
xaxis: {
type: 'datetime',
labels: {

View file

@ -1,6 +1,6 @@
<div>
<x-slot:title>
Advanced Settings | Coolify
Advanced Settings | MapleDeploy
</x-slot>
<x-settings.layout>
@ -36,14 +36,14 @@
['value' => false, 'label' => 'Disabled'],
]" />
<x-forms.input id="custom_dns_servers" label="Custom DNS servers"
helper="Comma-separated resolvers. Leave empty to use system defaults."
placeholder="1.1.1.1, 8.8.8.8" />
helper="Comma-separated resolvers (e.g., 149.112.121.10,149.112.122.10). Leave empty to use system defaults." {{-- MapleDeploy branding: Canadian Shield examples --}}
placeholder="149.112.121.10,149.112.122.10" />
</div>
</x-application.settings-section>
@if (isCloud())
<x-application.settings-section id="domain-connect-section" title="Domain Connect"
helper="Optional RSA private key used to sign Cloudflare Domain Connect apply URLs on Coolify Cloud. Leave blank to keep the existing key.">
helper="Optional RSA private key used to sign Cloudflare Domain Connect apply URLs. Leave blank to keep the existing key.">
<div class="grid gap-4">
<x-forms.input id="domain_connect_private_key" type="password" allowToPeak
label="Domain Connect private key (PEM)"
@ -70,7 +70,7 @@
<x-application.settings-section id="api-section" title="API and MCP">
<div class="grid gap-4 lg:grid-cols-2">
<x-forms.listbox id="is_api_enabled" label="API access"
helper="Allow authenticated requests to the Coolify REST API." onChange="instantSave"
helper="Allow authenticated requests to the REST API." onChange="instantSave"
:options="[
['value' => true, 'label' => 'Enabled'],
['value' => false, 'label' => 'Disabled'],
@ -127,18 +127,7 @@
['value' => false, 'label' => 'Enabled'],
['value' => true, 'label' => 'Disabled'],
]" />
<div class="flex flex-col gap-2">
<x-forms.listbox id="is_sponsorship_popup_enabled" label="Sponsorship reminders"
helper="Show the monthly project sponsorship reminder." onChange="instantSave" :options="[
['value' => true, 'label' => 'Enabled'],
['value' => false, 'label' => 'Disabled'],
]" />
@if (isDev())
<x-forms.button type="button" @click="$dispatch('show-sponsorship-reminder')">
Show sponsorship reminder
</x-forms.button>
@endif
</div>
{{-- MapleDeploy branding: sponsorship popup listbox removed (popup itself already removed) --}}
</div>
</x-application.settings-section>

View file

@ -1,6 +1,7 @@
@php
$consoleThemes = [
['key' => 'system', 'name' => 'System', 'background' => 'linear-gradient(135deg, #ffffff 0 50%, #121214 50% 100%)', 'accent' => '#8C8E9C'],
// MapleDeploy branding: stone remap (system/blur-black swatches: was #121214 / #8C8E9C)
['key' => 'system', 'name' => 'System', 'background' => 'linear-gradient(135deg, #ffffff 0 50%, color-mix(in oklab, #1c1917 50%, #0c0a09) 50% 100%)', 'accent' => '#78716c'],
['key' => 'shadows-midnight', 'name' => 'Midnight', 'background' => 'linear-gradient(135deg, #2a3b4c, rgba(42, 59, 76, 0.4))', 'accent' => '#6d7a7c'],
['key' => 'shadows-golden-hour', 'name' => 'Golden Hour', 'background' => 'linear-gradient(135deg, #d58a42, rgba(213, 138, 66, 0.4))', 'accent' => '#bf8c3c'],
['key' => 'shadows-cosmic-purple', 'name' => 'Cosmic Purple', 'background' => 'linear-gradient(135deg, #5d3e66, rgba(93, 62, 102, 0.4))', 'accent' => '#A76DBE'],
@ -10,7 +11,7 @@
['key' => 'shadows-golden-nebula', 'name' => 'Golden Nebula', 'background' => 'linear-gradient(135deg, #ffd700, #ff6347, #d4a20e, #ffcc00, #1f3d6f)', 'accent' => '#d4a20e'],
['key' => 'shadows-cosmic-lagoon', 'name' => 'Cosmic Lagoon', 'background' => 'linear-gradient(135deg, #1d2b64, #2f4f96, #00b5b8, #9c27b0, #8e24aa)', 'accent' => '#00b5b8'],
['key' => 'shadows-neon-nebula', 'name' => 'Neon Nebula', 'background' => 'linear-gradient(135deg, #00d9d9, #ff55aa, #1e1e2f, #2f3b57, #ff99ff)', 'accent' => '#ff55aa'],
['key' => 'shadows-transparent', 'name' => 'Blur Black', 'background' => 'rgba(0, 0, 0, 0.7)', 'accent' => '#8C8E9C'],
['key' => 'shadows-transparent', 'name' => 'Blur Black', 'background' => 'rgba(0, 0, 0, 0.7)', 'accent' => '#78716c'],
];
$consoleThemeKeys = collect($consoleThemes)->pluck('key')->values();
$consoleThemeNames = collect($consoleThemes)->pluck('name', 'key');
@ -279,7 +280,8 @@ class="px-2 py-5 text-center text-[11px] text-white/35">
</div>
</div>
@elseif ($servers->isEmpty())
<div class="flex h-full min-h-0 items-center justify-center bg-[#141414] px-4">
{{-- MapleDeploy branding: stone remap (was #141414) --}}
<div class="flex h-full min-h-0 items-center justify-center bg-stone-900 px-4">
<x-empty size="lg" title="No terminal targets available"
description="Connect a reachable server and enable terminal access to start a session."
icon-name="browser-terminal" />

View file

@ -1,7 +1,7 @@
#!/bin/bash
## Do not modify this file. You will lose the ability to autoupdate!
CDN="https://cdn.coollabs.io/coolify"
CDN="https://updates.mapledeploy.ca/coolify"
LATEST_IMAGE=${1:-latest}
LATEST_HELPER_VERSION=${2:-latest}
ENV_FILE="/data/coolify/source/.env"
@ -197,7 +197,7 @@ echo "3/6 Pulling Docker images..."
echo " This may take a few minutes depending on your connection."
# Also pull the helper image (not in compose files but needed for upgrade)
HELPER_IMAGE="${REGISTRY_URL:-docker.io}/coollabsio/coolify-helper:${LATEST_HELPER_VERSION}"
HELPER_IMAGE="ghcr.io/coollabsio/coolify-helper:${LATEST_HELPER_VERSION}"
echo " - Pulling $HELPER_IMAGE..."
log "Pulling image: $HELPER_IMAGE"
if docker pull "$HELPER_IMAGE" >>"$LOGFILE" 2>&1; then
@ -292,7 +292,7 @@ nohup bash -c "
fi
log 'Running docker compose up...'
docker run -v /data/coolify/source:/data/coolify/source -v /var/run/docker.sock:/var/run/docker.sock \${DOCKER_CONFIG_MOUNT} --rm \${REGISTRY_URL:-docker.io}/coollabsio/coolify-helper:\${LATEST_HELPER_VERSION} bash -c \"LATEST_IMAGE=\${LATEST_IMAGE} docker compose --env-file /data/coolify/source/.env \${COMPOSE_FILES} up -d --remove-orphans --wait --wait-timeout 60\" >>\"\$LOGFILE\" 2>&1
docker run -v /data/coolify/source:/data/coolify/source -v /var/run/docker.sock:/var/run/docker.sock \${DOCKER_CONFIG_MOUNT} --rm ghcr.io/coollabsio/coolify-helper:\${LATEST_HELPER_VERSION} bash -c \"LATEST_IMAGE=\${LATEST_IMAGE} docker compose --env-file /data/coolify/source/.env \${COMPOSE_FILES} up -d --remove-orphans --wait --wait-timeout 60\" >>\"\$LOGFILE\" 2>&1
log 'Docker compose up completed'
# Final log entry

View file

@ -183,8 +183,8 @@ services:
network:
interface: 172.28.0.1
dns:
- 1.1.1.1
- 1.0.0.1
- 149.112.121.10
- 149.112.122.10
name: pterodactyl_nw
ispn: false
driver: bridge

View file

@ -0,0 +1,32 @@
<?php
use App\Models\InstanceSettings;
use Database\Seeders\InstanceSettingsSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Vite;
uses(RefreshDatabase::class);
test('MapleDeploy instance settings seeder disables public registration by default', function () {
$this->seed(InstanceSettingsSeeder::class);
expect((bool) InstanceSettings::findOrFail(0)->is_registration_enabled)->toBeFalse();
});
test('login page does not redirect to registration when no users exist and registration is disabled', function () {
config()->set('app.maintenance.driver', 'file');
$this->app->instance(Vite::class, new class
{
public function __invoke(): string
{
return '';
}
});
$this->seed(InstanceSettingsSeeder::class);
$response = $this->get(route('login'));
$response->assertOk();
$response->assertViewIs('auth.login');
$response->assertViewHas('is_registration_enabled', false);
});

View file

@ -0,0 +1,65 @@
<?php
use App\Http\Middleware\DecideWhatToDoWithUser;
use App\Models\InstanceSettings;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Request;
uses(RefreshDatabase::class);
beforeEach(function () {
config([
'app.maintenance.store' => 'array',
'cache.default' => 'array',
]);
});
test('MapleDeploy root team admins log in to the managed root team', function () {
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0]));
$rootTeam = Team::factory()->create([
'id' => 0,
'name' => 'Root Team',
'personal_team' => false,
]);
$user = User::factory()->create([
'email' => 'member@example.com',
]);
expect($user->teams()->where('personal_team', true)->exists())->toBeTrue();
$user->teams()->syncWithoutDetaching([
$rootTeam->id => ['role' => 'admin'],
]);
$response = $this->post('/login', [
'email' => 'member@example.com',
'password' => 'password',
]);
$response->assertRedirect();
expect(session('currentTeam')?->id)->toBe(0);
});
test('MapleDeploy root team admin sessions are repaired from personal team to root team', function () {
$rootTeam = Team::factory()->create([
'id' => 0,
'name' => 'Root Team',
'personal_team' => false,
]);
$user = User::factory()->create([
'email' => 'member@example.com',
]);
$personalTeam = $user->teams()->where('personal_team', true)->firstOrFail();
$user->teams()->syncWithoutDetaching([
$rootTeam->id => ['role' => 'admin'],
]);
$this->actingAs($user);
session(['currentTeam' => $personalTeam]);
app(DecideWhatToDoWithUser::class)->handle(
Request::create('/'),
fn () => response('ok'),
);
expect(session('currentTeam')?->id)->toBe(0);
});

View file

@ -0,0 +1,91 @@
<?php
use App\Actions\Fortify\ResetUserPassword;
use App\Models\InstanceSettings;
use App\Models\User;
use App\Notifications\TransactionalEmails\ResetPassword;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Once;
use Illuminate\Validation\ValidationException;
uses(RefreshDatabase::class);
beforeEach(function () {
Notification::fake();
config([
'app.maintenance.driver' => 'file',
'cache.default' => 'array',
'session.driver' => 'array',
]);
InstanceSettings::unguarded(function () {
InstanceSettings::query()->create([
'id' => 0,
'smtp_enabled' => true,
'smtp_from_address' => 'test@example.com',
'smtp_from_name' => 'MapleDeploy',
'smtp_host' => 'localhost',
'smtp_port' => 1025,
]);
});
Once::flush();
});
test('forgot password does not create a reset token for MapleDeploy revoked users', function () {
$user = User::factory()->create([
'email' => 'revoked@example.com',
'remember_token' => 'mapledeploy-revoked:abc123',
]);
$response = $this->post('/forgot-password', [
'email' => 'revoked@example.com',
]);
$response->assertSessionHas('status');
expect(DB::table('password_reset_tokens')->where('email', $user->email)->exists())->toBeFalse();
Notification::assertNothingSent();
});
test('forgot password still sends reset links for active users', function () {
$user = User::factory()->create([
'email' => 'active@example.com',
'remember_token' => null,
]);
$response = $this->post('/forgot-password', [
'email' => 'active@example.com',
]);
$response->assertSessionHas('status');
expect(DB::table('password_reset_tokens')->where('email', $user->email)->exists())->toBeTrue();
Notification::assertSentTo($user, ResetPassword::class);
});
test('reset password refuses MapleDeploy revoked users even with an existing token', function () {
$user = User::factory()->create([
'password' => Hash::make('old-password'),
'remember_token' => 'mapledeploy-revoked:abc123',
]);
expect(fn () => app(ResetUserPassword::class)->reset($user, [
'password' => 'new-password',
'password_confirmation' => 'new-password',
]))->toThrow(ValidationException::class);
expect(Hash::check('old-password', $user->fresh()->password))->toBeTrue()
->and($user->fresh()->remember_token)->toBe('mapledeploy-revoked:abc123');
});
test('revoked users are logged out even when sessions are not database backed', function () {
$user = User::factory()->create([
'remember_token' => 'mapledeploy-revoked:abc123',
'email_verified_at' => now(),
]);
$response = $this->actingAs($user)->get('/');
$response->assertRedirect(route('login'));
$this->assertGuest();
});

View file

@ -0,0 +1,403 @@
<?php
use App\Models\InstanceSettings;
use App\Models\User;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Symfony\Component\Process\Process;
beforeEach(function () {
$this->originalDatabaseConfig = [
'default' => config('database.default'),
'testing_database' => config('database.connections.testing.database'),
];
$this->originalDatabaseEnvironment = [
'DB_CONNECTION' => [
'env' => $_ENV['DB_CONNECTION'] ?? null,
'server' => $_SERVER['DB_CONNECTION'] ?? null,
'process' => getenv('DB_CONNECTION') === false ? null : getenv('DB_CONNECTION'),
],
'DB_DATABASE' => [
'env' => $_ENV['DB_DATABASE'] ?? null,
'server' => $_SERVER['DB_DATABASE'] ?? null,
'process' => getenv('DB_DATABASE') === false ? null : getenv('DB_DATABASE'),
],
];
$this->databasePath = storage_path('framework/testing-mapledeploy-user-mgmt-'.bin2hex(random_bytes(6)).'.sqlite');
touch($this->databasePath);
config([
'database.default' => 'testing',
'database.connections.testing.database' => $this->databasePath,
]);
$_ENV['DB_CONNECTION'] = 'testing';
$_SERVER['DB_CONNECTION'] = 'testing';
$_ENV['DB_DATABASE'] = $this->databasePath;
$_SERVER['DB_DATABASE'] = $this->databasePath;
putenv('DB_CONNECTION=testing');
putenv("DB_DATABASE={$this->databasePath}");
$GLOBALS['mapledeployUserMgmtDatabasePath'] = $this->databasePath;
DB::purge('testing');
DB::reconnect('testing');
Artisan::call('migrate:fresh', ['--database' => 'testing']);
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0]));
});
afterEach(function () {
DB::disconnect('testing');
DB::purge('testing');
config([
'database.default' => $this->originalDatabaseConfig['default'] ?? null,
'database.connections.testing.database' => $this->originalDatabaseConfig['testing_database'] ?? null,
]);
if (isset($this->databasePath) && file_exists($this->databasePath)) {
unlink($this->databasePath);
}
foreach (($this->originalDatabaseEnvironment ?? []) as $key => $values) {
if ($values['env'] === null) {
unset($_ENV[$key]);
} else {
$_ENV[$key] = $values['env'];
}
if ($values['server'] === null) {
unset($_SERVER[$key]);
} else {
$_SERVER[$key] = $values['server'];
}
if ($values['process'] === null) {
putenv($key);
} else {
putenv("{$key}={$values['process']}");
}
}
unset($GLOBALS['mapledeployUserMgmtDatabasePath']);
});
function runMapledeployUserCommand(array $arguments, string $stdin = ''): array
{
$process = new Process(
[PHP_BINARY, 'artisan', ...$arguments],
base_path(),
[
'APP_ENV' => 'testing',
'APP_KEY' => config('app.key'),
'DB_CONNECTION' => 'testing',
'DB_DATABASE' => $GLOBALS['mapledeployUserMgmtDatabasePath'],
'CACHE_DRIVER' => 'array',
'SESSION_DRIVER' => 'database',
'QUEUE_CONNECTION' => 'sync',
'MAIL_MAILER' => 'array',
'SELF_HOSTED' => 'true',
],
);
$process->setInput($stdin);
$process->setTimeout(30);
$process->run();
$jsonLine = collect(explode("\n", $process->getOutput()))
->map(fn (string $line) => trim($line))
->first(fn (string $line) => str_starts_with($line, '{') && str_ends_with($line, '}'));
return [
'exitCode' => $process->getExitCode(),
'json' => $jsonLine ? json_decode($jsonLine, true, flags: JSON_THROW_ON_ERROR) : null,
'stdout' => $process->getOutput(),
'stderr' => $process->getErrorOutput(),
];
}
test('MapleDeploy user management commands create, list, reset, and revoke users', function () {
$admin = runMapledeployUserCommand([
'mapledeploy:user:create',
'--admin',
'--email=Owner@Example.com',
'--name=Owner',
], "owner-password\n");
expect($admin['exitCode'])->toBe(0)
->and($admin['json']['user'])->toMatchArray([
'id' => 0,
'email' => 'owner@example.com',
'name' => 'Owner',
])
->and((bool) InstanceSettings::findOrFail(0)->is_registration_enabled)->toBeFalse()
->and(Hash::check('owner-password', User::findOrFail(0)->password))->toBeTrue();
$duplicateOwner = runMapledeployUserCommand([
'mapledeploy:user:create',
'--email=OWNER@Example.com',
'--name=Duplicate Owner',
], "duplicate-password\n");
expect($duplicateOwner['exitCode'])->toBe(1)
->and($duplicateOwner['json'])->toBe(['error' => 'EMAIL_EXISTS']);
$member = runMapledeployUserCommand([
'mapledeploy:user:create',
'--email=Member@Example.com',
'--name=Member',
'--team-role=admin',
], "member-password\n");
expect($member['exitCode'])->toBe(0)
->and($member['json']['user']['email'])->toBe('member@example.com');
$memberUser = User::whereEmail('member@example.com')->firstOrFail();
expect($memberUser->teams()->where('teams.id', 0)->first()?->pivot?->role)->toBe('admin')
->and($memberUser->teams()->pluck('teams.id')->all())->toBe([0]);
$list = runMapledeployUserCommand(['mapledeploy:user:list']);
expect($list['exitCode'])->toBe(0)
->and(collect($list['json']['users'])->pluck('email')->all())
->toBe(['owner@example.com', 'member@example.com']);
$resetOtherUser = User::factory()->create();
DB::table('sessions')->insert([
[
'id' => 'member-reset-session',
'user_id' => $memberUser->id,
'ip_address' => '127.0.0.1',
'user_agent' => 'Test Browser',
'payload' => base64_encode('member-reset-payload'),
'last_activity' => now()->timestamp,
],
[
'id' => 'other-reset-session',
'user_id' => $resetOtherUser->id,
'ip_address' => '127.0.0.1',
'user_agent' => 'Test Browser',
'payload' => base64_encode('other-reset-payload'),
'last_activity' => now()->timestamp,
],
]);
$reset = runMapledeployUserCommand([
'mapledeploy:user:set-password',
(string) $memberUser->id,
], "new-member-password\n");
expect($reset['exitCode'])->toBe(0)
->and(Hash::check('new-member-password', $memberUser->fresh()->password))->toBeTrue();
expect(DB::table('sessions')->where('user_id', $memberUser->id)->count())->toBe(0)
->and(DB::table('sessions')->where('user_id', $resetOtherUser->id)->count())->toBe(1);
DB::table('personal_access_tokens')->insert([
'tokenable_type' => User::class,
'tokenable_id' => $memberUser->id,
'name' => 'e2e-token',
'token' => hash('sha256', 'e2e-token'),
'team_id' => '0',
'abilities' => json_encode(['*'], JSON_THROW_ON_ERROR),
'created_at' => now(),
'updated_at' => now(),
]);
expect($memberUser->tokens()->count())->toBe(1);
$otherUser = User::factory()->create();
DB::table('sessions')->insert([
[
'id' => 'member-session',
'user_id' => $memberUser->id,
'ip_address' => '127.0.0.1',
'user_agent' => 'Test Browser',
'payload' => base64_encode('member-payload'),
'last_activity' => now()->timestamp,
],
[
'id' => 'other-session',
'user_id' => $otherUser->id,
'ip_address' => '127.0.0.1',
'user_agent' => 'Test Browser',
'payload' => base64_encode('other-payload'),
'last_activity' => now()->timestamp,
],
]);
expect(DB::table('sessions')->where('user_id', $memberUser->id)->count())->toBe(1);
$revokeRoot = runMapledeployUserCommand(['mapledeploy:user:revoke', '0']);
expect($revokeRoot['exitCode'])->toBe(1)
->and($revokeRoot['json'])->toBe(['error' => 'CANNOT_REVOKE_ROOT_USER']);
$revoke = runMapledeployUserCommand([
'mapledeploy:user:revoke',
(string) $memberUser->id,
]);
expect($revoke['exitCode'])->toBe(0)
->and($revoke['json']['revoked']['email'])->toBe('member@example.com')
->and($memberUser->fresh()->tokens()->count())->toBe(0);
expect(str_starts_with((string) $memberUser->fresh()->remember_token, 'mapledeploy-revoked:'))->toBeTrue();
expect(DB::table('sessions')->where('user_id', $memberUser->id)->count())->toBe(0)
->and(DB::table('sessions')->where('user_id', $otherUser->id)->count())->toBe(1);
$restore = runMapledeployUserCommand([
'mapledeploy:user:set-password',
(string) $memberUser->id,
], "restored-member-password\n");
expect($restore['exitCode'])->toBe(0)
->and(Hash::check('restored-member-password', $memberUser->fresh()->password))->toBeTrue()
->and($memberUser->fresh()->remember_token)->toBeNull();
});
test('MapleDeploy user delete command removes non-root users', function () {
runMapledeployUserCommand([
'mapledeploy:user:create',
'--admin',
'--email=owner@example.com',
'--name=Owner',
], "owner-password\n");
$member = runMapledeployUserCommand([
'mapledeploy:user:create',
'--email=delete-me@example.com',
'--name=Delete Me',
'--team-role=admin',
], "member-password\n");
$memberUser = User::findOrFail($member['json']['user']['id']);
DB::table('personal_access_tokens')->insert([
'tokenable_type' => User::class,
'tokenable_id' => $memberUser->id,
'name' => 'delete-token',
'token' => hash('sha256', 'delete-token'),
'team_id' => '0',
'abilities' => json_encode(['*'], JSON_THROW_ON_ERROR),
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('sessions')->insert([
'id' => 'delete-session',
'user_id' => $memberUser->id,
'ip_address' => '127.0.0.1',
'user_agent' => 'Test Browser',
'payload' => base64_encode('delete-payload'),
'last_activity' => now()->timestamp,
]);
$deleteRoot = runMapledeployUserCommand(['mapledeploy:user:delete', '0']);
expect($deleteRoot['exitCode'])->toBe(1)
->and($deleteRoot['json'])->toBe(['error' => 'CANNOT_DELETE_ROOT_USER']);
$invalid = runMapledeployUserCommand(['mapledeploy:user:delete', 'not-a-user-id']);
expect($invalid['exitCode'])->toBe(1)
->and($invalid['json'])->toBe(['error' => 'INVALID_USER_ID']);
$delete = runMapledeployUserCommand([
'mapledeploy:user:delete',
(string) $memberUser->id,
]);
expect($delete['exitCode'])->toBe(0)
->and($delete['json']['deleted'])->toBe([
'id' => $memberUser->id,
'email' => 'delete-me@example.com',
])
->and(User::find($memberUser->id))->toBeNull()
->and(DB::table('personal_access_tokens')->where('tokenable_id', $memberUser->id)->count())->toBe(0)
->and(DB::table('sessions')->where('user_id', $memberUser->id)->count())->toBe(0);
$missing = runMapledeployUserCommand([
'mapledeploy:user:delete',
(string) $memberUser->id,
]);
expect($missing['exitCode'])->toBe(0)
->and($missing['json'])->toBe([
'deleted' => null,
'alreadyDeleted' => true,
'id' => $memberUser->id,
]);
});
test('MapleDeploy password command can transfer root ownership identity', function () {
runMapledeployUserCommand([
'mapledeploy:user:create',
'--admin',
'--email=old-owner@example.com',
'--name=Old Owner',
], "old-owner-password\n");
DB::table('sessions')->insert([
'id' => 'root-session',
'user_id' => 0,
'ip_address' => '127.0.0.1',
'user_agent' => 'Test Browser',
'payload' => base64_encode('root-payload'),
'last_activity' => now()->timestamp,
]);
$claim = runMapledeployUserCommand([
'mapledeploy:user:set-password',
'0',
'--email=New.Owner@Example.com',
'--name=New Owner',
], "new-owner-password\n");
$root = User::findOrFail(0);
expect($claim['exitCode'])->toBe(0)
->and($claim['json']['user'])->toMatchArray([
'id' => 0,
'email' => 'new.owner@example.com',
'name' => 'New Owner',
])
->and($root->email)->toBe('new.owner@example.com')
->and($root->name)->toBe('New Owner')
->and(Hash::check('new-owner-password', $root->password))->toBeTrue()
->and($root->remember_token)->toBeNull()
->and($root->email_verified_at)->not->toBeNull();
expect(User::whereEmail('old-owner@example.com')->exists())->toBeFalse();
expect(DB::table('sessions')->where('user_id', 0)->count())->toBe(0);
});
test('MapleDeploy password command promotes matched native users to root team admin', function () {
runMapledeployUserCommand([
'mapledeploy:user:create',
'--admin',
'--email=owner@example.com',
'--name=Owner',
], "owner-password\n");
$nativeUser = User::factory()->create([
'email' => 'native-member@example.com',
'name' => 'Native Member',
]);
expect($nativeUser->teams()->where('teams.id', 0)->exists())->toBeFalse();
$reset = runMapledeployUserCommand([
'mapledeploy:user:set-password',
(string) $nativeUser->id,
], "native-member-password\n");
expect($reset['exitCode'])->toBe(0)
->and(Hash::check('native-member-password', $nativeUser->fresh()->password))->toBeTrue()
->and($nativeUser->fresh()->teams()->where('teams.id', 0)->first()?->pivot?->role)->toBe('admin');
});
test('MapleDeploy password command rejects ownership transfer to an existing email', function () {
runMapledeployUserCommand([
'mapledeploy:user:create',
'--admin',
'--email=old-owner@example.com',
'--name=Old Owner',
], "old-owner-password\n");
$existing = User::factory()->create(['email' => 'new.owner@example.com']);
$claim = runMapledeployUserCommand([
'mapledeploy:user:set-password',
'0',
'--email=new.owner@example.com',
'--name=New Owner',
], "new-owner-password\n");
expect($claim['exitCode'])->toBe(1)
->and($claim['json'])->toBe(['error' => 'EMAIL_EXISTS'])
->and(User::findOrFail(0)->email)->toBe('old-owner@example.com')
->and($existing->fresh()->email)->toBe('new.owner@example.com');
});
test('MapleDeploy user creation command reports validation errors as JSON', function () {
$invalid = runMapledeployUserCommand([
'mapledeploy:user:create',
'--email=not-an-email',
'--name=Invalid',
], "short\n");
expect($invalid['exitCode'])->toBe(1)
->and($invalid['json'])->toBe(['error' => 'INVALID_INPUT']);
});