Compare commits
4
Commits
5db36cb29f
...
e7c878525d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7c878525d | ||
|
|
6a6900435c | ||
|
|
7c38ffff57 | ||
|
|
71972afb87 |
@@ -4,9 +4,10 @@
|
|||||||
; sudo supervisorctl start skinbase-ssr
|
; sudo supervisorctl start skinbase-ssr
|
||||||
;
|
;
|
||||||
; Path uses the stable app symlink (REMOTE_FOLDER), not a versioned release dir.
|
; Path uses the stable app symlink (REMOTE_FOLDER), not a versioned release dir.
|
||||||
; Override node path if needed. Deploy restarts this via SSR_SUPERVISOR_PROGRAM=skinbase-ssr.
|
; Supervisor has a minimal PATH, so call node by absolute path. Deploy restarts
|
||||||
|
; this as the SSH login user via: sudo -n supervisorctl restart skinbase-ssr
|
||||||
[program:skinbase-ssr]
|
[program:skinbase-ssr]
|
||||||
command=/usr/bin/env node /opt/www/virtual/SkinbaseNova/bootstrap/ssr/ssr.js
|
command=/usr/local/bin/node /opt/www/virtual/SkinbaseNova/bootstrap/ssr/ssr.js
|
||||||
directory=/opt/www/virtual/SkinbaseNova
|
directory=/opt/www/virtual/SkinbaseNova
|
||||||
process_name=%(program_name)s
|
process_name=%(program_name)s
|
||||||
numprocs=1
|
numprocs=1
|
||||||
|
|||||||
@@ -19,6 +19,26 @@ bash deploy.sh
|
|||||||
|
|
||||||
`bash sync.sh` remains as a legacy alias for the same flow.
|
`bash sync.sh` remains as a legacy alias for the same flow.
|
||||||
|
|
||||||
|
Production deploys require a reproducible Git source tree by default
|
||||||
|
(`REQUIRE_CLEAN_GIT=1`). The preflight inspects staged, tracked, and
|
||||||
|
deployable untracked files; untracked paths excluded by rsync are ignored.
|
||||||
|
The local Vite build may refresh the tracked generated SSR bundle under
|
||||||
|
`bootstrap/ssr/`, but source/config/deploy changes made during preparation
|
||||||
|
abort before the release is switched. The local Git `HEAD` is captured before
|
||||||
|
build and must remain unchanged through rsync.
|
||||||
|
|
||||||
|
Run the local-only guard when validating a release without creating a remote
|
||||||
|
release or running rsync:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash sync.sh --preflight-only
|
||||||
|
```
|
||||||
|
|
||||||
|
`--skip-build` is rejected for a clean production deploy unless
|
||||||
|
`deploy.cmd` has just completed the Windows build and exported
|
||||||
|
`WINDOWS_FRONTEND_BUILT=1`. An explicit `REQUIRE_CLEAN_GIT=0` is reserved for
|
||||||
|
non-production/custom workflows and is not the production default.
|
||||||
|
|
||||||
`deploy.cmd` runs `npm.cmd run build` on Windows first, then enters WSL for rsync/ssh. That is required when the Ubuntu distro cannot execute Windows `.exe` files (`Exec format error` on `powershell.exe`). If WSL interop does work, `bash deploy.sh` can still launch `npm.cmd` through PowerShell. Local Linux `php`/`composer` are not required for a normal deploy; Artisan and Composer run on the production server. `--with-tests` uses WSL `php` when present, otherwise Windows `php.exe`.
|
`deploy.cmd` runs `npm.cmd run build` on Windows first, then enters WSL for rsync/ssh. That is required when the Ubuntu distro cannot execute Windows `.exe` files (`Exec format error` on `powershell.exe`). If WSL interop does work, `bash deploy.sh` can still launch `npm.cmd` through PowerShell. Local Linux `php`/`composer` are not required for a normal deploy; Artisan and Composer run on the production server. `--with-tests` uses WSL `php` when present, otherwise Windows `php.exe`.
|
||||||
|
|
||||||
This will:
|
This will:
|
||||||
|
|||||||
@@ -1197,13 +1197,13 @@ M18D ✅ remove neighbor prefetch HTTP
|
|||||||
M18E ✅ collapse navigation HTTP request
|
M18E ✅ collapse navigation HTTP request
|
||||||
M18F ✅ production measurement
|
M18F ✅ production measurement
|
||||||
M18G ✅ Similar-AI phase 2
|
M18G ✅ Similar-AI phase 2
|
||||||
|
M18H ✅ artwork SSR optimization
|
||||||
|
M18I ✅ view tracking
|
||||||
|
M18J ✅ rank request
|
||||||
|
M18K ✅ comments
|
||||||
|
M19 profiles
|
||||||
|
|
||||||
NEXT:
|
NEXT:
|
||||||
M18H artwork SSR optimization
|
|
||||||
M18I view tracking
|
|
||||||
M18J rank request
|
|
||||||
M18K comments
|
|
||||||
M19 profiles
|
|
||||||
M20 discover/home
|
M20 discover/home
|
||||||
M21 remaining legacy traffic
|
M21 remaining legacy traffic
|
||||||
M22 final infra re-audit
|
M22 final infra re-audit
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
import { formatProfileDate } from '../../lib/profileFormatters'
|
||||||
import AchievementBadge from './AchievementBadge'
|
import AchievementBadge from './AchievementBadge'
|
||||||
|
|
||||||
function cx(...parts) {
|
function cx(...parts) {
|
||||||
@@ -46,9 +47,9 @@ export default function AchievementCard({ achievement, className = '' }) {
|
|||||||
|
|
||||||
{achievement?.unlocked_at ? (
|
{achievement?.unlocked_at ? (
|
||||||
<p className="mt-3 text-xs text-slate-500">
|
<p className="mt-3 text-xs text-slate-500">
|
||||||
Unlocked {new Date(achievement.unlocked_at).toLocaleDateString()}
|
Unlocked {formatProfileDate(achievement.unlocked_at, { year: 'numeric', month: 'short', day: 'numeric' })}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</article>
|
</article>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
import { formatProfileDate, formatProfileNumber } from '../../lib/profileFormatters'
|
||||||
|
|
||||||
function formatDate(value) {
|
function formatDate(value) {
|
||||||
if (!value) return null
|
if (!value) return null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return new Intl.DateTimeFormat('en', {
|
return formatProfileDate(value, { month: 'short', day: 'numeric', year: 'numeric' })
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
year: 'numeric',
|
|
||||||
}).format(new Date(value))
|
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -17,7 +14,7 @@ function formatDate(value) {
|
|||||||
function formatYear(value) {
|
function formatYear(value) {
|
||||||
if (!value) return null
|
if (!value) return null
|
||||||
try {
|
try {
|
||||||
return new Intl.DateTimeFormat('en', { year: 'numeric' }).format(new Date(value))
|
return formatProfileDate(value, { year: 'numeric' })
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -268,8 +265,8 @@ function YearlyProductivityChart({ recaps }) {
|
|||||||
<div className="absolute inset-y-0 left-0 flex w-full items-center px-3 opacity-0 transition-opacity group-hover:opacity-100">
|
<div className="absolute inset-y-0 left-0 flex w-full items-center px-3 opacity-0 transition-opacity group-hover:opacity-100">
|
||||||
<span className="text-[11px] font-semibold text-white drop-shadow-sm">
|
<span className="text-[11px] font-semibold text-white drop-shadow-sm">
|
||||||
{uploads} upload{uploads !== 1 ? 's' : ''}
|
{uploads} upload{uploads !== 1 ? 's' : ''}
|
||||||
{(item.metrics?.views ?? 0) > 0 ? ` · ${Number(item.metrics.views).toLocaleString()} views` : ''}
|
{(item.metrics?.views ?? 0) > 0 ? ` · ${formatProfileNumber(item.metrics.views)} views` : ''}
|
||||||
{(item.metrics?.downloads ?? 0) > 0 ? ` · ${Number(item.metrics.downloads).toLocaleString()} dl` : ''}
|
{(item.metrics?.downloads ?? 0) > 0 ? ` · ${formatProfileNumber(item.metrics.downloads)} dl` : ''}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -292,8 +289,8 @@ function YearlyProductivityChart({ recaps }) {
|
|||||||
{/* Summary footer */}
|
{/* Summary footer */}
|
||||||
<div className="mt-5 flex flex-wrap gap-4 border-t border-white/5 pt-4 text-[12px] text-slate-400">
|
<div className="mt-5 flex flex-wrap gap-4 border-t border-white/5 pt-4 text-[12px] text-slate-400">
|
||||||
<span><span className="font-semibold text-white">{sorted.length}</span> active years</span>
|
<span><span className="font-semibold text-white">{sorted.length}</span> active years</span>
|
||||||
<span><span className="font-semibold text-white">{sorted.reduce((s, r) => s + r.metrics.uploads_count, 0).toLocaleString()}</span> total uploads</span>
|
<span><span className="font-semibold text-white">{formatProfileNumber(sorted.reduce((s, r) => s + r.metrics.uploads_count, 0))}</span> total uploads</span>
|
||||||
<span><span className="font-semibold text-white">{Number(sorted.reduce((s, r) => s + (r.metrics.views ?? 0), 0)).toLocaleString()}</span> total views</span>
|
<span><span className="font-semibold text-white">{formatProfileNumber(sorted.reduce((s, r) => s + (r.metrics.views ?? 0), 0))}</span> total views</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -550,8 +547,8 @@ export default function CreatorJourneySection({ journey, username }) {
|
|||||||
<p className="mt-3 text-sm leading-relaxed text-slate-300">{item.summary}</p>
|
<p className="mt-3 text-sm leading-relaxed text-slate-300">{item.summary}</p>
|
||||||
|
|
||||||
<div className="mt-4 grid gap-2 sm:grid-cols-2">
|
<div className="mt-4 grid gap-2 sm:grid-cols-2">
|
||||||
<StatPill label="Views" value={(item.metrics?.views ?? 0).toLocaleString()} />
|
<StatPill label="Views" value={formatProfileNumber(item.metrics?.views ?? 0)} />
|
||||||
<StatPill label="Downloads" value={(item.metrics?.downloads ?? 0).toLocaleString()} />
|
<StatPill label="Downloads" value={formatProfileNumber(item.metrics?.downloads ?? 0)} />
|
||||||
{(item.metrics?.featured_count ?? 0) > 0 && (
|
{(item.metrics?.featured_count ?? 0) > 0 && (
|
||||||
<StatPill label="Featured" value={item.metrics.featured_count} />
|
<StatPill label="Featured" value={item.metrics.featured_count} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import CollectionVisibilityBadge from './CollectionVisibilityBadge'
|
import CollectionVisibilityBadge from './CollectionVisibilityBadge'
|
||||||
|
import { formatProfileDate, formatProfileNumber } from '../../../lib/profileFormatters'
|
||||||
|
|
||||||
async function requestJson(url, { method = 'GET', body } = {}) {
|
async function requestJson(url, { method = 'GET', body } = {}) {
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
@@ -26,14 +27,7 @@ async function requestJson(url, { method = 'GET', body } = {}) {
|
|||||||
function formatUpdated(value) {
|
function formatUpdated(value) {
|
||||||
if (!value) return 'Updated recently'
|
if (!value) return 'Updated recently'
|
||||||
|
|
||||||
const date = new Date(value)
|
return formatProfileDate(value, { month: 'short', day: 'numeric', year: 'numeric' }) || 'Updated recently'
|
||||||
if (Number.isNaN(date.getTime())) return 'Updated recently'
|
|
||||||
|
|
||||||
return new Intl.DateTimeFormat(undefined, {
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
year: 'numeric',
|
|
||||||
}).format(date)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatPill({ icon, label, value }) {
|
function StatPill({ icon, label, value }) {
|
||||||
@@ -178,7 +172,7 @@ export default function CollectionCard({ collection, isOwner, onDelete, onToggle
|
|||||||
{collection?.subtitle ? <p className="mt-1 truncate text-sm text-slate-300">{collection.subtitle}</p> : null}
|
{collection?.subtitle ? <p className="mt-1 truncate text-sm text-slate-300">{collection.subtitle}</p> : null}
|
||||||
{collection?.owner?.name ? <p className="mt-1 truncate text-sm text-slate-400">Curated by {collection.owner.name}{collection?.owner?.username ? ` • @${collection.owner.username}` : ''}</p> : null}
|
{collection?.owner?.name ? <p className="mt-1 truncate text-sm text-slate-400">Curated by {collection.owner.name}{collection?.owner?.username ? ` • @${collection.owner.username}` : ''}</p> : null}
|
||||||
<p className="mt-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">
|
<p className="mt-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">
|
||||||
{(collection?.artworks_count ?? 0).toLocaleString()} artworks
|
{formatProfileNumber(collection?.artworks_count ?? 0)} artworks
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -190,9 +184,9 @@ export default function CollectionCard({ collection, isOwner, onDelete, onToggle
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="mt-4 flex flex-wrap gap-2">
|
<div className="mt-4 flex flex-wrap gap-2">
|
||||||
<StatPill icon="fa-heart" label="likes" value={(collection?.likes_count ?? 0).toLocaleString()} />
|
<StatPill icon="fa-heart" label="likes" value={formatProfileNumber(collection?.likes_count ?? 0)} />
|
||||||
<StatPill icon="fa-bell" label="followers" value={(collection?.followers_count ?? 0).toLocaleString()} />
|
<StatPill icon="fa-bell" label="followers" value={formatProfileNumber(collection?.followers_count ?? 0)} />
|
||||||
{collection?.collaborators_count > 1 ? <StatPill icon="fa-user-group" label="curators" value={(collection?.collaborators_count ?? 0).toLocaleString()} /> : null}
|
{collection?.collaborators_count > 1 ? <StatPill icon="fa-user-group" label="curators" value={formatProfileNumber(collection?.collaborators_count ?? 0)} /> : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 flex items-center justify-between gap-3 text-xs text-slate-400">
|
<div className="mt-4 flex items-center justify-between gap-3 text-xs text-slate-400">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React from 'react'
|
|||||||
import { usePage } from '@inertiajs/react'
|
import { usePage } from '@inertiajs/react'
|
||||||
import CreatorJourneySection from '../CreatorJourneySection'
|
import CreatorJourneySection from '../CreatorJourneySection'
|
||||||
import { shinyFlagUrl } from '../../../utils/flagUrl'
|
import { shinyFlagUrl } from '../../../utils/flagUrl'
|
||||||
|
import { formatProfileDate, formatProfileNumber } from '../../../lib/profileFormatters'
|
||||||
|
|
||||||
const SOCIAL_ICONS = {
|
const SOCIAL_ICONS = {
|
||||||
twitter: { icon: 'fa-brands fa-x-twitter', label: 'X / Twitter', hoverClass: 'hover:border-slate-300/30 hover:text-slate-100 hover:bg-white/[0.08]' },
|
twitter: { icon: 'fa-brands fa-x-twitter', label: 'X / Twitter', hoverClass: 'hover:border-slate-300/30 hover:text-slate-100 hover:bg-white/[0.08]' },
|
||||||
@@ -66,7 +67,7 @@ function getContentTypeIcon(label) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatNumber(value) {
|
function formatNumber(value) {
|
||||||
return Number(value ?? 0).toLocaleString()
|
return formatProfileNumber(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatRelativeDate(value) {
|
function formatRelativeDate(value) {
|
||||||
@@ -79,7 +80,7 @@ function formatRelativeDate(value) {
|
|||||||
const now = new Date()
|
const now = new Date()
|
||||||
const diffSeconds = Math.round((date.getTime() - now.getTime()) / 1000)
|
const diffSeconds = Math.round((date.getTime() - now.getTime()) / 1000)
|
||||||
const absSeconds = Math.abs(diffSeconds)
|
const absSeconds = Math.abs(diffSeconds)
|
||||||
const formatter = new Intl.RelativeTimeFormat('en', { numeric: 'auto' })
|
const formatter = new Intl.RelativeTimeFormat('en-US', { numeric: 'auto' })
|
||||||
|
|
||||||
if (absSeconds < 3600) {
|
if (absSeconds < 3600) {
|
||||||
return formatter.format(Math.round(diffSeconds / 60), 'minute')
|
return formatter.format(Math.round(diffSeconds / 60), 'minute')
|
||||||
@@ -110,7 +111,7 @@ function formatShortDate(value) {
|
|||||||
const date = new Date(value)
|
const date = new Date(value)
|
||||||
if (Number.isNaN(date.getTime())) return null
|
if (Number.isNaN(date.getTime())) return null
|
||||||
|
|
||||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
return formatProfileDate(date, { month: 'short', day: 'numeric', year: 'numeric' })
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -131,7 +132,7 @@ function formatContributionDate(value) {
|
|||||||
const date = new Date(value)
|
const date = new Date(value)
|
||||||
if (Number.isNaN(date.getTime())) return null
|
if (Number.isNaN(date.getTime())) return null
|
||||||
|
|
||||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
return formatProfileDate(date, { month: 'short', day: 'numeric', year: 'numeric' })
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -237,14 +238,13 @@ export default function TabAbout({ user, profile, stats, achievements, worldRewa
|
|||||||
const flagUrl = shinyFlagUrl(profile?.country_code, props?.cdn?.files_url)
|
const flagUrl = shinyFlagUrl(profile?.country_code, props?.cdn?.files_url)
|
||||||
|
|
||||||
const joinDate = user.created_at
|
const joinDate = user.created_at
|
||||||
? new Date(user.created_at).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })
|
? formatProfileDate(user.created_at, { month: 'long', day: 'numeric', year: 'numeric' })
|
||||||
: null
|
: null
|
||||||
|
|
||||||
const lastVisit = user.last_visit_at
|
const lastVisit = user.last_visit_at
|
||||||
? (() => {
|
? (() => {
|
||||||
try {
|
try {
|
||||||
const d = new Date(user.last_visit_at)
|
return formatProfileDate(user.last_visit_at, { month: 'short', day: 'numeric', year: 'numeric' })
|
||||||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
|
||||||
} catch { return null }
|
} catch { return null }
|
||||||
})()
|
})()
|
||||||
: null
|
: null
|
||||||
@@ -254,7 +254,7 @@ export default function TabAbout({ user, profile, stats, achievements, worldRewa
|
|||||||
const birthDate = profile?.birthdate
|
const birthDate = profile?.birthdate
|
||||||
? (() => {
|
? (() => {
|
||||||
try {
|
try {
|
||||||
return new Date(profile.birthdate).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })
|
return formatProfileDate(profile.birthdate, { month: 'long', day: 'numeric', year: 'numeric' })
|
||||||
} catch { return null }
|
} catch { return null }
|
||||||
})()
|
})()
|
||||||
: null
|
: null
|
||||||
@@ -381,9 +381,9 @@ export default function TabAbout({ user, profile, stats, achievements, worldRewa
|
|||||||
{entry.group?.headline ? <p className="mt-1 text-sm text-slate-400">{truncateText(entry.group.headline, 100)}</p> : null}
|
{entry.group?.headline ? <p className="mt-1 text-sm text-slate-400">{truncateText(entry.group.headline, 100)}</p> : null}
|
||||||
{entry.summary ? <p className="mt-3 text-sm text-slate-300">{entry.summary}</p> : null}
|
{entry.summary ? <p className="mt-3 text-sm text-slate-300">{entry.summary}</p> : null}
|
||||||
<div className="mt-3 flex flex-wrap gap-3 text-xs text-slate-500">
|
<div className="mt-3 flex flex-wrap gap-3 text-xs text-slate-500">
|
||||||
<span>{Number(entry.counts?.credited_artworks || 0).toLocaleString()} credited artworks</span>
|
<span>{formatProfileNumber(entry.counts?.credited_artworks || 0)} credited artworks</span>
|
||||||
<span>{Number(entry.counts?.releases || 0).toLocaleString()} releases</span>
|
<span>{formatProfileNumber(entry.counts?.releases || 0)} releases</span>
|
||||||
<span>{Number(entry.counts?.projects || 0).toLocaleString()} projects</span>
|
<span>{formatProfileNumber(entry.counts?.projects || 0)} projects</span>
|
||||||
{entry.joined_at ? <span>Joined {formatContributionDate(entry.joined_at)}</span> : null}
|
{entry.joined_at ? <span>Joined {formatContributionDate(entry.joined_at)}</span> : null}
|
||||||
</div>
|
</div>
|
||||||
{Array.isArray(entry.role_labels) && entry.role_labels.length > 0 ? (
|
{Array.isArray(entry.role_labels) && entry.role_labels.length > 0 ? (
|
||||||
|
|||||||
@@ -1,17 +1,11 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
import { formatProfileDate, formatProfileNumber } from '../../../lib/profileFormatters'
|
||||||
|
|
||||||
function formatFollowedAt(value) {
|
function formatFollowedAt(value) {
|
||||||
if (!value) return null
|
if (!value) return null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const date = new Date(value)
|
return formatProfileDate(value, { year: 'numeric', month: 'short', day: 'numeric' })
|
||||||
if (Number.isNaN(date.getTime())) return null
|
|
||||||
|
|
||||||
return date.toLocaleDateString(undefined, {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
})
|
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -36,7 +30,7 @@ export default function TabFollowers({ listing, username, followerCount }) {
|
|||||||
<i className="fa-solid fa-users text-sky-400 fa-fw" />
|
<i className="fa-solid fa-users text-sky-400 fa-fw" />
|
||||||
Followers
|
Followers
|
||||||
<span className="rounded bg-white/5 px-1.5 py-0.5 text-[10px] font-medium normal-case tracking-normal text-slate-400">
|
<span className="rounded bg-white/5 px-1.5 py-0.5 text-[10px] font-medium normal-case tracking-normal text-slate-400">
|
||||||
{total.toLocaleString()}
|
{formatProfileNumber(total)}
|
||||||
</span>
|
</span>
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
import { formatProfileNumber } from '../../../lib/profileFormatters'
|
||||||
|
|
||||||
function KpiCard({ icon, label, value, color = 'text-sky-400' }) {
|
function KpiCard({ icon, label, value, color = 'text-sky-400' }) {
|
||||||
return (
|
return (
|
||||||
@@ -7,7 +8,7 @@ function KpiCard({ icon, label, value, color = 'text-sky-400' }) {
|
|||||||
<i className={`fa-solid ${icon} text-xl`} />
|
<i className={`fa-solid ${icon} text-xl`} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-2xl font-bold text-white tabular-nums">{Number(value ?? 0).toLocaleString()}</p>
|
<p className="text-2xl font-bold text-white tabular-nums">{formatProfileNumber(value)}</p>
|
||||||
<p className="text-xs text-slate-500 mt-0.5 uppercase tracking-wider">{label}</p>
|
<p className="text-xs text-slate-500 mt-0.5 uppercase tracking-wider">{label}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -71,7 +72,7 @@ export default function TabStats({ stats, followerCount, followAnalytics }) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<div className="text-xs uppercase tracking-widest text-slate-500">Weighted Score</div>
|
<div className="text-xs uppercase tracking-widest text-slate-500">Weighted Score</div>
|
||||||
<div className="mt-1 text-2xl font-bold text-white tabular-nums">{Number(medalTotals?.score_total ?? 0).toLocaleString()}</div>
|
<div className="mt-1 text-2xl font-bold text-white tabular-nums">{formatProfileNumber(medalTotals?.score_total ?? 0)}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 grid grid-cols-2 gap-3 md:grid-cols-4">
|
<div className="mt-4 grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||||
@@ -83,7 +84,7 @@ export default function TabStats({ stats, followerCount, followAnalytics }) {
|
|||||||
].map((item) => (
|
].map((item) => (
|
||||||
<div key={item.label} className="rounded-2xl border border-white/10 bg-black/15 px-4 py-4">
|
<div key={item.label} className="rounded-2xl border border-white/10 bg-black/15 px-4 py-4">
|
||||||
<div className="text-[11px] uppercase tracking-widest text-slate-500">{item.label}</div>
|
<div className="text-[11px] uppercase tracking-widest text-slate-500">{item.label}</div>
|
||||||
<div className={`mt-2 text-2xl font-semibold tabular-nums ${item.color}`}>{Number(item.value).toLocaleString()}</div>
|
<div className={`mt-2 text-2xl font-semibold tabular-nums ${item.color}`}>{formatProfileNumber(item.value)}</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
import { formatProfileDate } from '../../../lib/profileFormatters'
|
||||||
import ProfileWorldRecognitionBadge from './ProfileWorldRecognitionBadge'
|
import ProfileWorldRecognitionBadge from './ProfileWorldRecognitionBadge'
|
||||||
|
|
||||||
function formatDate(value) {
|
function formatDate(value) {
|
||||||
if (!value) return null
|
if (!value) return null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const date = new Date(value)
|
return formatProfileDate(value, { month: 'short', day: 'numeric', year: 'numeric' })
|
||||||
if (Number.isNaN(date.getTime())) return null
|
|
||||||
|
|
||||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -119,4 +117,4 @@ export default function ProfileWorldHistoryCard({ entry }) {
|
|||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
import { formatProfileDate } from '../../../lib/profileFormatters'
|
||||||
import ProfileWorldStatsRow from './ProfileWorldStatsRow'
|
import ProfileWorldStatsRow from './ProfileWorldStatsRow'
|
||||||
import ProfileWorldRecognitionBadge from './ProfileWorldRecognitionBadge'
|
import ProfileWorldRecognitionBadge from './ProfileWorldRecognitionBadge'
|
||||||
|
|
||||||
@@ -6,10 +7,7 @@ function formatDate(value) {
|
|||||||
if (!value) return null
|
if (!value) return null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const date = new Date(value)
|
return formatProfileDate(value, { month: 'short', day: 'numeric', year: 'numeric' })
|
||||||
if (Number.isNaN(date.getTime())) return null
|
|
||||||
|
|
||||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -51,4 +49,4 @@ export default function ProfileWorldHistorySummary({ history }) {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import NovaConfirmDialog from '../ui/NovaConfirmDialog'
|
import NovaConfirmDialog from '../ui/NovaConfirmDialog'
|
||||||
|
import { formatProfileNumber } from '../../lib/profileFormatters'
|
||||||
|
|
||||||
export default function FollowButton({
|
export default function FollowButton({
|
||||||
username,
|
username,
|
||||||
@@ -79,7 +80,7 @@ export default function FollowButton({
|
|||||||
>
|
>
|
||||||
<i className={`fa-solid fa-fw ${loading ? 'fa-circle-notch fa-spin' : following ? (hovered ? 'fa-user-minus' : 'fa-user-check') : 'fa-user-plus'}`} />
|
<i className={`fa-solid fa-fw ${loading ? 'fa-circle-notch fa-spin' : following ? (hovered ? 'fa-user-minus' : 'fa-user-check') : 'fa-user-plus'}`} />
|
||||||
<span>{following ? (hovered ? 'Unfollow' : 'Following') : 'Follow'}</span>
|
<span>{following ? (hovered ? 'Unfollow' : 'Following') : 'Follow'}</span>
|
||||||
{showCount ? <span className="text-xs opacity-70">{count.toLocaleString()}</span> : null}
|
{showCount ? <span className="text-xs opacity-70">{formatProfileNumber(count)}</span> : null}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<NovaConfirmDialog
|
<NovaConfirmDialog
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set +e
|
|
||||||
ssh_bin=ssh
|
|
||||||
remote_server=[email protected]
|
|
||||||
key=/tmp/skinbase-deploy-ssh/identities/id_ed25519
|
|
||||||
opts=(-o BatchMode=yes -o StrictHostKeyChecking=accept-new -o ConnectTimeout=15 -o IdentitiesOnly=yes)
|
|
||||||
if [[ -S /tmp/skinbase-deploy-ssh/mux-klevze_server3.klevze.si.sock ]]; then
|
|
||||||
opts+=(-o ControlPath=/tmp/skinbase-deploy-ssh/mux-klevze_server3.klevze.si.sock)
|
|
||||||
fi
|
|
||||||
if [[ -f "$key" ]]; then
|
|
||||||
opts+=(-i "$key")
|
|
||||||
fi
|
|
||||||
|
|
||||||
"$ssh_bin" "${opts[@]}" "$remote_server" 'sudo -n -l'
|
|
||||||
echo EXIT:$?
|
|
||||||
echo '--- ps ---'
|
|
||||||
"$ssh_bin" "${opts[@]}" "$remote_server" "ps -ef | grep -E 'ssr|inertia|supervisor' | grep -v grep"
|
|
||||||
echo '--- status ---'
|
|
||||||
"$ssh_bin" "${opts[@]}" "$remote_server" 'sudo -n /usr/bin/supervisorctl status'
|
|
||||||
echo '--- restart ---'
|
|
||||||
"$ssh_bin" "${opts[@]}" "$remote_server" 'sudo -n /usr/bin/supervisorctl restart skinbase-ssr; echo rc=$?'
|
|
||||||
@@ -123,6 +123,7 @@ ensure_local_deploy_dirs() {
|
|||||||
# metadata fast: short SHA/branch always; unstaged dirty scans are skipped on
|
# metadata fast: short SHA/branch always; unstaged dirty scans are skipped on
|
||||||
# slow filesystems and otherwise hard-timeout'd.
|
# slow filesystems and otherwise hard-timeout'd.
|
||||||
git_metadata_timeout_seconds="${GIT_METADATA_TIMEOUT_SECONDS:-8}"
|
git_metadata_timeout_seconds="${GIT_METADATA_TIMEOUT_SECONDS:-8}"
|
||||||
|
clean_git_timeout_seconds="${CLEAN_GIT_TIMEOUT_SECONDS:-60}"
|
||||||
|
|
||||||
git_workdir_is_slow() {
|
git_workdir_is_slow() {
|
||||||
case "$local_folder" in
|
case "$local_folder" in
|
||||||
@@ -192,6 +193,65 @@ detect_git_dirty_fast() {
|
|||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
|
|
||||||
|
deploy_path_is_rsync_excluded() {
|
||||||
|
local path="${1#./}"
|
||||||
|
path="${path%/}"
|
||||||
|
|
||||||
|
case "$path" in
|
||||||
|
.git|.git/*|.deploy|.deploy/*|.cursor|.cursor/*|.venv|.venv/*|.vscode|.vscode/*|node_modules|node_modules/*|vendor|vendor/*|storage|storage/*|tests|tests/*|playwright-report|playwright-report/*|test-results|test-results/*|public/build|public/build/*|public/files|public/files/*|public/storage|public/storage/*|resources/lang|resources/lang/*|bootstrap/cache|bootstrap/cache/*|var/deploy|var/deploy/*|var/php-tmp|var/php-tmp/*|var/php-sessions|var/php-sessions/*|.cache|.cache/*|.config|.config/*|.composer|.composer/*|.npm|.npm/*|.local|.local/*|.copilot|.copilot/*|oldSite|oldSite/*|.phpintel|.phpintel/*)
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
public/hot|public/sitemap.xml|public/sitemaps/*|.env|.env.*|.phpunit.result.cache)
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# Returns: 0 deployable changes, 1 no deployable changes, 2 unable to prove.
|
||||||
|
# Unlike detect_git_dirty_fast(), this is intentionally conservative: a
|
||||||
|
# production clean-git gate must inspect untracked files and may not silently
|
||||||
|
# skip the worktree scan on WSL/network filesystems.
|
||||||
|
detect_deployable_git_dirty() {
|
||||||
|
local status_output=""
|
||||||
|
local rc=0
|
||||||
|
local line=""
|
||||||
|
local status_code=""
|
||||||
|
local path=""
|
||||||
|
|
||||||
|
run_git_with_timeout "$clean_git_timeout_seconds" diff --cached --quiet >/dev/null 2>&1 || rc=$?
|
||||||
|
if [[ "$rc" -eq 1 ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if [[ "$rc" -ne 0 ]]; then
|
||||||
|
return 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
rc=0
|
||||||
|
status_output="$(run_git_with_timeout "$clean_git_timeout_seconds" -c core.untrackedCache=false status --porcelain=v1 --untracked-files=all 2>/dev/null)" || rc=$?
|
||||||
|
if [[ "$rc" -ne 0 ]]; then
|
||||||
|
return 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
while IFS= read -r line; do
|
||||||
|
[[ -n "$line" ]] || continue
|
||||||
|
status_code="${line:0:2}"
|
||||||
|
path="${line:3}"
|
||||||
|
|
||||||
|
# Untracked files in paths excluded by the deployment rsync are not
|
||||||
|
# deployable. Tracked changes remain dirty even when their path is
|
||||||
|
# excluded, so accidental edits cannot be hidden by this allowance.
|
||||||
|
if [[ "$status_code" == "??" ]] && deploy_path_is_rsync_excluded "$path"; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
return 0
|
||||||
|
done <<< "$status_output"
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
collect_git_metadata() {
|
collect_git_metadata() {
|
||||||
local dirty_rc=1
|
local dirty_rc=1
|
||||||
|
|
||||||
|
|||||||
+130
-27
@@ -57,9 +57,14 @@ php_fpm_service="${PHP_FPM_SERVICE:-php8.4-fpm}"
|
|||||||
ssr_supervisor_program="${SSR_SUPERVISOR_PROGRAM:-skinbase-ssr}"
|
ssr_supervisor_program="${SSR_SUPERVISOR_PROGRAM:-skinbase-ssr}"
|
||||||
# SSH login user stays klevze@...; remote file/composer/artisan work runs as this app user.
|
# SSH login user stays klevze@...; remote file/composer/artisan work runs as this app user.
|
||||||
remote_app_user="${REMOTE_APP_USER:-skinbase}"
|
remote_app_user="${REMOTE_APP_USER:-skinbase}"
|
||||||
require_clean_git="${REQUIRE_CLEAN_GIT:-0}"
|
# Production deploys must originate from a clean, reproducible source tree.
|
||||||
|
# An explicit REQUIRE_CLEAN_GIT=0 remains available for non-production/custom
|
||||||
|
# workflows, but is intentionally not the default.
|
||||||
|
require_clean_git="${REQUIRE_CLEAN_GIT:-1}"
|
||||||
required_git_branch="${REQUIRED_GIT_BRANCH:-}"
|
required_git_branch="${REQUIRED_GIT_BRANCH:-}"
|
||||||
db_sync_remote_maintenance=0
|
db_sync_remote_maintenance=0
|
||||||
|
preflight_only=0
|
||||||
|
head_sha_before=""
|
||||||
|
|
||||||
declare -a rsync_args=()
|
declare -a rsync_args=()
|
||||||
declare -a ssh_base_opts=()
|
declare -a ssh_base_opts=()
|
||||||
@@ -78,6 +83,7 @@ Options:
|
|||||||
--with-tests Run the local test command before build/sync. Default command: php artisan test.
|
--with-tests Run the local test command before build/sync. Default command: php artisan test.
|
||||||
--skip-migrate Skip php artisan migrate on the server.
|
--skip-migrate Skip php artisan migrate on the server.
|
||||||
--dry-run Print the planned rsync/deploy actions without changing the remote server.
|
--dry-run Print the planned rsync/deploy actions without changing the remote server.
|
||||||
|
--preflight-only Run local source/build checks and exit before remote release creation or rsync.
|
||||||
--release-id ID Override the generated release version label used for the remote release directory.
|
--release-id ID Override the generated release version label used for the remote release directory.
|
||||||
--build-number N Override the monotonic local build number for this deploy.
|
--build-number N Override the monotonic local build number for this deploy.
|
||||||
--keep-releases N Keep the latest N remote releases ready for server-side switching. Default: 5.
|
--keep-releases N Keep the latest N remote releases ready for server-side switching. Default: 5.
|
||||||
@@ -104,7 +110,7 @@ Options:
|
|||||||
--no-rollback Disable automatic rollback to the previous release when the switched release fails before health/safe point.
|
--no-rollback Disable automatic rollback to the previous release when the switched release fails before health/safe point.
|
||||||
--reload-php-fpm Try to reload PHP-FPM after release switch. Uses PHP_FPM_SERVICE, default php8.4-fpm.
|
--reload-php-fpm Try to reload PHP-FPM after release switch. Uses PHP_FPM_SERVICE, default php8.4-fpm.
|
||||||
--no-php-fpm-reload Explicitly skip PHP-FPM reload.
|
--no-php-fpm-reload Explicitly skip PHP-FPM reload.
|
||||||
--require-clean-git Refuse deploy when the local Git working tree has uncommitted changes.
|
--require-clean-git Refuse deploy when the local Git working tree has uncommitted deployable changes (default).
|
||||||
--required-branch BRANCH
|
--required-branch BRANCH
|
||||||
Refuse deploy unless the local Git branch matches BRANCH.
|
Refuse deploy unless the local Git branch matches BRANCH.
|
||||||
--no-rsync-progress Disable rsync transfer progress output.
|
--no-rsync-progress Disable rsync transfer progress output.
|
||||||
@@ -119,6 +125,7 @@ Environment overrides:
|
|||||||
RELOAD_PHP_FPM, PHP_FPM_SERVICE, REQUIRE_CLEAN_GIT, REQUIRED_GIT_BRANCH,
|
RELOAD_PHP_FPM, PHP_FPM_SERVICE, REQUIRE_CLEAN_GIT, REQUIRED_GIT_BRANCH,
|
||||||
ALLOW_DEPLOY_FROM_DOT_DEPLOY, FULL_UPGRADE_PRE_HOOK, FULL_UPGRADE_POST_HOOK,
|
ALLOW_DEPLOY_FROM_DOT_DEPLOY, FULL_UPGRADE_PRE_HOOK, FULL_UPGRADE_POST_HOOK,
|
||||||
GIT_METADATA_TIMEOUT_SECONDS, WINDOWS_SSH_DIR, WINDOWS_FRONTEND_BUILT
|
GIT_METADATA_TIMEOUT_SECONDS, WINDOWS_SSH_DIR, WINDOWS_FRONTEND_BUILT
|
||||||
|
CLEAN_GIT_TIMEOUT_SECONDS
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
SSH still authenticates as REMOTE_SERVER (e.g. klevze@host). Remote rsync/composer/artisan
|
SSH still authenticates as REMOTE_SERVER (e.g. klevze@host). Remote rsync/composer/artisan
|
||||||
@@ -491,21 +498,77 @@ guard_git_state() {
|
|||||||
|
|
||||||
if [[ "$require_clean_git" == "1" ]]; then
|
if [[ "$require_clean_git" == "1" ]]; then
|
||||||
dirty_rc=1
|
dirty_rc=1
|
||||||
detect_git_dirty_fast && dirty_rc=0 || dirty_rc=$?
|
detect_deployable_git_dirty && dirty_rc=0 || dirty_rc=$?
|
||||||
if [[ "$dirty_rc" -eq 0 ]]; then
|
if [[ "$dirty_rc" -eq 0 ]]; then
|
||||||
die "Working tree has uncommitted changes. Commit/stash them or disable REQUIRE_CLEAN_GIT."
|
die "Working tree has uncommitted deployable changes. Commit them before production deploy."
|
||||||
fi
|
fi
|
||||||
if [[ "$dirty_rc" -ne 1 ]]; then
|
if [[ "$dirty_rc" -ne 1 ]]; then
|
||||||
die "Could not prove a clean Git worktree on this filesystem (dirty check skipped or timed out). Disable REQUIRE_CLEAN_GIT or deploy from a native Linux checkout."
|
die "Could not prove a clean Git worktree before deployment (dirty check timed out or failed). Refusing deploy."
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
capture_head_sha() {
|
||||||
|
head_sha_before=""
|
||||||
|
if command -v git >/dev/null 2>&1 && git -C "$local_folder" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||||
|
head_sha_before="$(git -C "$local_folder" rev-parse HEAD 2>/dev/null || true)"
|
||||||
|
[[ "$head_sha_before" =~ ^[0-9a-f]{40}$ ]] || die "Could not capture the local Git HEAD before build/sync."
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_head_stable() {
|
||||||
|
[[ -n "$head_sha_before" ]] || return 0
|
||||||
|
local current_head
|
||||||
|
current_head="$(git -C "$local_folder" rev-parse HEAD 2>/dev/null || true)"
|
||||||
|
[[ "$current_head" == "$head_sha_before" ]] || die "Local Git HEAD changed during deployment preparation; refusing remote release switch."
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_post_build_source_state() {
|
||||||
|
[[ "$require_clean_git" == "1" ]] || return 0
|
||||||
|
|
||||||
|
# deploy.cmd completed the Windows build before this WSL process started.
|
||||||
|
# The initial clean-source gate already ran after that build; preflight-only
|
||||||
|
# must not repeat the expensive full status walk on a /mnt filesystem.
|
||||||
|
if [[ "$preflight_only" -eq 1 && "${WINDOWS_FRONTEND_BUILT:-0}" == "1" ]]; then
|
||||||
|
assert_head_stable
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
local status_output=""
|
||||||
|
local line=""
|
||||||
|
local status_code=""
|
||||||
|
local path=""
|
||||||
|
local rc=0
|
||||||
|
|
||||||
|
assert_head_stable
|
||||||
|
status_output="$(run_git_with_timeout "$clean_git_timeout_seconds" -c core.untrackedCache=false status --porcelain=v1 --untracked-files=all 2>/dev/null)" || rc=$?
|
||||||
|
[[ "$rc" -eq 0 ]] || die "Could not verify the Git worktree after the local build; refusing remote release switch."
|
||||||
|
|
||||||
|
while IFS= read -r line; do
|
||||||
|
[[ -n "$line" ]] || continue
|
||||||
|
status_code="${line:0:2}"
|
||||||
|
path="${line:3}"
|
||||||
|
|
||||||
|
# Vite owns the tracked SSR bundle. Its generated output is allowed to be
|
||||||
|
# refreshed by this deploy's local build, but source/config/deploy edits
|
||||||
|
# are never allowed to pass the final guard.
|
||||||
|
if [[ "$path" == bootstrap/ssr/* ]]; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
if [[ "$status_code" == "??" ]] && deploy_path_is_rsync_excluded "$path"; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
die "Git worktree changed outside generated SSR output after build: $path"
|
||||||
|
done <<< "$status_output"
|
||||||
|
}
|
||||||
|
|
||||||
run_preflight_checks() {
|
run_preflight_checks() {
|
||||||
log_step "Running local preflight checks"
|
log_step "Running local preflight checks"
|
||||||
|
|
||||||
require_command "$ssh_bin" "SSH"
|
if [[ "$preflight_only" -eq 0 ]]; then
|
||||||
require_command "$rsync_bin" "rsync"
|
require_command "$ssh_bin" "SSH"
|
||||||
|
require_command "$rsync_bin" "rsync"
|
||||||
|
fi
|
||||||
require_local_php_if_needed
|
require_local_php_if_needed
|
||||||
|
|
||||||
[[ -f "$local_folder/artisan" ]] || die "Expected Laravel artisan entrypoint at $local_folder/artisan."
|
[[ -f "$local_folder/artisan" ]] || die "Expected Laravel artisan entrypoint at $local_folder/artisan."
|
||||||
@@ -529,6 +592,12 @@ run_preflight_checks() {
|
|||||||
write_build_info_file
|
write_build_info_file
|
||||||
print_deploy_banner
|
print_deploy_banner
|
||||||
guard_git_state
|
guard_git_state
|
||||||
|
if [[ "$preflight_only" -eq 1 ]]; then
|
||||||
|
log_info "Preflight-only mode: skipping SSH, remote app-user, and remote release checks"
|
||||||
|
log_info "Required local deploy tools are available"
|
||||||
|
mark_phase_complete "preflight"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
acquire_local_deploy_lock
|
acquire_local_deploy_lock
|
||||||
configure_ssh_transport
|
configure_ssh_transport
|
||||||
verify_remote_app_user_access
|
verify_remote_app_user_access
|
||||||
@@ -637,13 +706,18 @@ run_frontend_build() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
validate_local_build_artifacts() {
|
validate_local_build_artifacts() {
|
||||||
[[ "$run_local_build" -eq 1 ]] || return 0
|
|
||||||
|
|
||||||
local missing=0
|
local missing=0
|
||||||
|
local validation_context=""
|
||||||
|
|
||||||
|
if [[ "$run_local_build" -eq 1 ]]; then
|
||||||
|
validation_context="after local build"
|
||||||
|
else
|
||||||
|
validation_context="from existing local artifacts"
|
||||||
|
fi
|
||||||
|
|
||||||
if [[ ! -f "$local_folder/public/build/manifest.json" ]]; then
|
if [[ ! -f "$local_folder/public/build/manifest.json" ]]; then
|
||||||
if [[ "$require_build_manifest" == "1" ]]; then
|
if [[ "$require_build_manifest" == "1" ]]; then
|
||||||
die "Vite manifest missing at public/build/manifest.json after build. Refusing deploy. Set REQUIRE_BUILD_MANIFEST=0 only for intentional custom build paths."
|
die "Vite manifest missing at public/build/manifest.json ${validation_context}. Refusing deploy. Set REQUIRE_BUILD_MANIFEST=0 only for intentional custom build paths."
|
||||||
fi
|
fi
|
||||||
log_warn "Vite manifest was not found at public/build/manifest.json after build. Continuing because REQUIRE_BUILD_MANIFEST=0."
|
log_warn "Vite manifest was not found at public/build/manifest.json after build. Continuing because REQUIRE_BUILD_MANIFEST=0."
|
||||||
missing=1
|
missing=1
|
||||||
@@ -651,14 +725,14 @@ validate_local_build_artifacts() {
|
|||||||
|
|
||||||
if [[ ! -f "$local_folder/bootstrap/ssr/ssr.js" && ! -f "$local_folder/bootstrap/ssr/ssr.mjs" ]]; then
|
if [[ ! -f "$local_folder/bootstrap/ssr/ssr.js" && ! -f "$local_folder/bootstrap/ssr/ssr.mjs" ]]; then
|
||||||
if [[ "$require_build_manifest" == "1" ]]; then
|
if [[ "$require_build_manifest" == "1" ]]; then
|
||||||
die "SSR build artifact missing under bootstrap/ssr/ after build. Refusing deploy."
|
die "SSR build artifact missing under bootstrap/ssr/ ${validation_context}. Refusing deploy."
|
||||||
fi
|
fi
|
||||||
log_warn "SSR build artifact was not found under bootstrap/ssr/. Continuing because REQUIRE_BUILD_MANIFEST=0."
|
log_warn "SSR build artifact was not found under bootstrap/ssr/. Continuing because REQUIRE_BUILD_MANIFEST=0."
|
||||||
missing=1
|
missing=1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "$missing" -eq 0 ]]; then
|
if [[ "$missing" -eq 0 ]]; then
|
||||||
log_info "Local build artifacts validated"
|
log_info "Local build artifacts validated (${validation_context})"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1087,8 +1161,8 @@ EOF_PUBLIC_LINK
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Supervisor listens on a root socket. The app user (skinbase) cannot talk to it,
|
# Supervisor listens on a root socket. The app user (skinbase) cannot talk to it,
|
||||||
# and artisan inertia:start-ssr fights the supervised process. Restart as the
|
# and a second unmanaged SSR process fights the supervised process. Restart
|
||||||
# SSH login user with passwordless sudo supervisorctl.
|
# as the SSH login user with passwordless sudo supervisorctl.
|
||||||
restart_remote_inertia_ssr() {
|
restart_remote_inertia_ssr() {
|
||||||
[[ "$skip_ssr_restart" -eq 0 ]] || return 0
|
[[ "$skip_ssr_restart" -eq 0 ]] || return 0
|
||||||
|
|
||||||
@@ -1100,32 +1174,37 @@ restart_remote_inertia_ssr() {
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cd /tmp >/dev/null 2>&1 || cd / >/dev/null 2>&1 || true
|
cd /tmp >/dev/null 2>&1 || cd / >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
# sudoers on production is exact-match NOPASSWD for:
|
||||||
|
# /usr/bin/supervisorctl status <program>
|
||||||
|
# /usr/bin/supervisorctl restart <program>
|
||||||
|
# Do not call `supervisorctl status` without a program name, or `start`.
|
||||||
supervisorctl_bin="/usr/bin/supervisorctl"
|
supervisorctl_bin="/usr/bin/supervisorctl"
|
||||||
if [[ ! -x "$supervisorctl_bin" ]]; then
|
[[ -x "$supervisorctl_bin" ]] || {
|
||||||
supervisorctl_bin="$(command -v supervisorctl 2>/dev/null || true)"
|
printf 'ERROR: %s not found on the remote host.\n' "$supervisorctl_bin" >&2
|
||||||
fi
|
|
||||||
[[ -n "$supervisorctl_bin" ]] || {
|
|
||||||
printf 'ERROR: supervisorctl not found on the remote host.\n' >&2
|
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
ctl() {
|
ctl_status() {
|
||||||
sudo -n "$supervisorctl_bin" "$@"
|
sudo -n "$supervisorctl_bin" status "$SSR_PROGRAM"
|
||||||
}
|
}
|
||||||
|
|
||||||
status_line="$(ctl status "$SSR_PROGRAM" 2>/dev/null || true)"
|
ctl_restart() {
|
||||||
|
sudo -n "$supervisorctl_bin" restart "$SSR_PROGRAM"
|
||||||
|
}
|
||||||
|
|
||||||
|
status_line="$(ctl_status 2>/dev/null || true)"
|
||||||
if ! printf '%s\n' "$status_line" | grep -q "^${SSR_PROGRAM}[[:space:]]"; then
|
if ! printf '%s\n' "$status_line" | grep -q "^${SSR_PROGRAM}[[:space:]]"; then
|
||||||
printf 'ERROR: Supervisor program %s not found, or sudo -n supervisorctl is not permitted for this SSH user.\n' "$SSR_PROGRAM" >&2
|
printf 'ERROR: Supervisor program %s not found, or sudo -n supervisorctl is not permitted.\n' "$SSR_PROGRAM" >&2
|
||||||
ctl status >&2 || true
|
printf '%s\n' "$status_line" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
ctl restart "$SSR_PROGRAM"
|
ctl_restart
|
||||||
|
|
||||||
status=""
|
status=""
|
||||||
i=0
|
i=0
|
||||||
while [[ "$i" -lt 10 ]]; do
|
while [[ "$i" -lt 10 ]]; do
|
||||||
status="$(ctl status "$SSR_PROGRAM" 2>/dev/null | awk '{print $2}' || true)"
|
status="$(ctl_status 2>/dev/null | awk '{print $2}' || true)"
|
||||||
if [[ "$status" == "RUNNING" ]]; then
|
if [[ "$status" == "RUNNING" ]]; then
|
||||||
printf ' -> Supervisor program %s is RUNNING\n' "$SSR_PROGRAM"
|
printf ' -> Supervisor program %s is RUNNING\n' "$SSR_PROGRAM"
|
||||||
exit 0
|
exit 0
|
||||||
@@ -1135,7 +1214,7 @@ while [[ "$i" -lt 10 ]]; do
|
|||||||
done
|
done
|
||||||
|
|
||||||
printf 'ERROR: Supervisor program %s did not reach RUNNING after restart (status: %s).\n' "$SSR_PROGRAM" "${status:-unknown}" >&2
|
printf 'ERROR: Supervisor program %s did not reach RUNNING after restart (status: %s).\n' "$SSR_PROGRAM" "${status:-unknown}" >&2
|
||||||
ctl status "$SSR_PROGRAM" >&2 || true
|
ctl_status >&2 || true
|
||||||
exit 1
|
exit 1
|
||||||
EOF_SSR_RESTART
|
EOF_SSR_RESTART
|
||||||
}
|
}
|
||||||
@@ -1178,6 +1257,9 @@ while [[ $# -gt 0 ]]; do
|
|||||||
--dry-run)
|
--dry-run)
|
||||||
dry_run=1
|
dry_run=1
|
||||||
;;
|
;;
|
||||||
|
--preflight-only)
|
||||||
|
preflight_only=1
|
||||||
|
;;
|
||||||
--release-id)
|
--release-id)
|
||||||
shift
|
shift
|
||||||
release_id="$(sanitize_release_fragment "${1:?Missing value for --release-id}")"
|
release_id="$(sanitize_release_fragment "${1:?Missing value for --release-id}")"
|
||||||
@@ -1321,6 +1403,7 @@ collect_git_metadata
|
|||||||
allocate_build_number
|
allocate_build_number
|
||||||
trap 'rc=$?; on_local_exit "$rc"; exit "$rc"' EXIT
|
trap 'rc=$?; on_local_exit "$rc"; exit "$rc"' EXIT
|
||||||
run_preflight_checks
|
run_preflight_checks
|
||||||
|
capture_head_sha
|
||||||
|
|
||||||
if [[ "$run_db_sync" -eq 1 && "$db_sync_source" != "local" ]]; then
|
if [[ "$run_db_sync" -eq 1 && "$db_sync_source" != "local" ]]; then
|
||||||
die "Refusing DB sync without an explicit source. Use --with-db-from=local."
|
die "Refusing DB sync without an explicit source. Use --with-db-from=local."
|
||||||
@@ -1355,6 +1438,22 @@ if [[ "$run_local_build" -eq 1 ]]; then
|
|||||||
fi
|
fi
|
||||||
validate_local_build_artifacts
|
validate_local_build_artifacts
|
||||||
mark_phase_complete "frontend-build"
|
mark_phase_complete "frontend-build"
|
||||||
|
else
|
||||||
|
if [[ "$require_clean_git" == "1" && "${WINDOWS_FRONTEND_BUILT:-0}" != "1" ]]; then
|
||||||
|
die "--skip-build is not allowed for a clean production deploy unless WINDOWS_FRONTEND_BUILT=1 is set by deploy.cmd after a successful local build."
|
||||||
|
fi
|
||||||
|
validate_local_build_artifacts
|
||||||
|
fi
|
||||||
|
|
||||||
|
# This catches a source edit or HEAD change made after the initial preflight.
|
||||||
|
# It runs before any remote release is created, and again after rsync before
|
||||||
|
# the remote release can be switched.
|
||||||
|
assert_post_build_source_state
|
||||||
|
|
||||||
|
if [[ "$preflight_only" -eq 1 ]]; then
|
||||||
|
log_step "Local deploy preflight complete"
|
||||||
|
log_info "No remote release was created and no rsync was performed"
|
||||||
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
build_rsync_args
|
build_rsync_args
|
||||||
@@ -1403,6 +1502,10 @@ log_step "Syncing release ${release_id} (build #${build_number}) to $remote_serv
|
|||||||
"$rsync_bin" "${rsync_args[@]}" "$local_folder/" "$remote_server:$(remote_release_path)/"
|
"$rsync_bin" "${rsync_args[@]}" "$local_folder/" "$remote_server:$(remote_release_path)/"
|
||||||
mark_phase_complete "rsync"
|
mark_phase_complete "rsync"
|
||||||
|
|
||||||
|
# Rsync has populated only the staging release. Refuse to switch it if the
|
||||||
|
# source tree changed while the transfer was being prepared.
|
||||||
|
assert_post_build_source_state
|
||||||
|
|
||||||
if [[ "$run_db_sync" -eq 1 ]]; then
|
if [[ "$run_db_sync" -eq 1 ]]; then
|
||||||
enable_remote_maintenance_for_db_sync
|
enable_remote_maintenance_for_db_sync
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user