Skip to content

Commit 3a5d96e

Browse files
committed
feat(updates): add API endpoints for update management, backup, changelog, system info, and version check
- Implemented POST /api/updates/apply to trigger self-hosted updates via Docker Compose. - Created POST /api/updates/backup for PostgreSQL database backups before updates. - Added GET /api/updates/changelog to parse and return structured changelog entries from CHANGELOG.md. - Developed GET /api/updates/system to provide system information for diagnostics. - Introduced GET /api/updates/version to compare the running version against the latest GitHub release.
1 parent 60e0840 commit 3a5d96e

10 files changed

Lines changed: 1969 additions & 267 deletions

File tree

CHANGELOG.md

Lines changed: 0 additions & 265 deletions
Large diffs are not rendered by default.

SELF-HOSTING.md

Lines changed: 664 additions & 0 deletions
Large diffs are not rendered by default.

app/components/AppTopBar.vue

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import {
44
Kanban, FileText, LogOut, Table2,
55
Sun, Moon, MessageSquarePlus, Settings,
66
ChevronDown, Menu, X, Users, ChevronLeft,
7-
LayoutDashboard, Calendar,
7+
LayoutDashboard, Calendar, ArrowUpCircle,
88
} from 'lucide-vue-next'
99
1010
const route = useRoute()
@@ -196,6 +196,18 @@ onUnmounted(() => document.removeEventListener('click', onClickOutsideUser))
196196
<Moon v-else class="size-4" />
197197
</button>
198198

199+
<!-- Updates button -->
200+
<NuxtLink
201+
:to="$localePath('/dashboard/updates')"
202+
class="hidden sm:flex items-center justify-center size-8 rounded-lg transition-all duration-200 no-underline"
203+
:class="isActiveRoute('/dashboard/updates', false)
204+
? 'text-brand-600 dark:text-brand-400 bg-brand-50 dark:bg-brand-950/40'
205+
: 'text-surface-500 dark:text-surface-400 hover:text-surface-700 dark:hover:text-surface-200 hover:bg-surface-100 dark:hover:bg-surface-800'"
206+
title="Updates & changelog"
207+
>
208+
<ArrowUpCircle class="size-4" />
209+
</NuxtLink>
210+
199211
<!-- Feedback button -->
200212
<button
201213
v-if="isFeedbackEnabled"

app/pages/dashboard/updates.vue

Lines changed: 822 additions & 0 deletions
Large diffs are not rendered by default.

docker-compose.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ services:
5252
condition: service_healthy
5353
minio:
5454
condition: service_healthy
55+
volumes:
56+
- backups_data:/data/backups
5557

5658
# Optional DB browser — run with: docker compose --profile tools up
5759
adminer:
@@ -67,4 +69,5 @@ services:
6769

6870
volumes:
6971
postgres_data:
70-
minio_data:
72+
minio_data:
73+
backups_data:

server/api/updates/apply.post.ts

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import { execFile } from 'node:child_process'
2+
import { promisify } from 'node:util'
3+
4+
const execFileAsync = promisify(execFile)
5+
6+
interface UpdateResult {
7+
success: boolean
8+
message: string
9+
previousVersion: string | null
10+
steps: { step: string; status: 'success' | 'failed'; detail?: string }[]
11+
}
12+
13+
/**
14+
* POST /api/updates/apply
15+
*
16+
* Triggers a self-hosted update via Docker Compose.
17+
* This endpoint orchestrates the update process:
18+
* 1. Pulls latest code from the default branch
19+
* 2. Rebuilds the Docker containers
20+
* 3. Restarts the application
21+
*
22+
* Only works for Docker-based deployments. Railway deployments
23+
* auto-update via GitHub integration.
24+
*
25+
* Requires authentication (owner only).
26+
*/
27+
export default defineEventHandler(async (event) => {
28+
await requirePermission(event, { organization: ['delete'] })
29+
30+
const steps: UpdateResult['steps'] = []
31+
32+
// Read current version before update
33+
const { readFile } = await import('node:fs/promises')
34+
const { resolve } = await import('node:path')
35+
let previousVersion: string | null = null
36+
try {
37+
const pkg = await readFile(resolve(process.cwd(), 'package.json'), 'utf-8')
38+
previousVersion = JSON.parse(pkg).version
39+
}
40+
catch {
41+
previousVersion = null
42+
}
43+
44+
// Verify we're running in Docker
45+
try {
46+
const { access } = await import('node:fs/promises')
47+
await access('/.dockerenv')
48+
}
49+
catch {
50+
return {
51+
success: false,
52+
message: 'Updates via UI are only available for Docker-based deployments. For other deployment methods, please update manually.',
53+
previousVersion,
54+
steps: [],
55+
} satisfies UpdateResult
56+
}
57+
58+
// Verify required commands are available
59+
for (const cmd of ['git', 'docker'] as const) {
60+
try {
61+
await execFileAsync('which', [cmd], { timeout: 5_000 })
62+
}
63+
catch {
64+
return {
65+
success: false,
66+
message: `The "${cmd}" command is not available inside this container. One-click updates require git and docker CLI to be installed in the container image, and the Docker socket to be mounted. Please update manually instead.`,
67+
previousVersion,
68+
steps: [],
69+
} satisfies UpdateResult
70+
}
71+
}
72+
73+
// Step 1: Pull latest changes
74+
try {
75+
const { stdout } = await execFileAsync('git', ['pull', 'origin', 'main'], {
76+
cwd: '/app',
77+
timeout: 120_000,
78+
})
79+
steps.push({
80+
step: 'Pull latest code',
81+
status: 'success',
82+
detail: stdout.trim(),
83+
})
84+
}
85+
catch (err: unknown) {
86+
const message = err instanceof Error ? err.message : 'Unknown error'
87+
steps.push({ step: 'Pull latest code', status: 'failed', detail: message })
88+
return {
89+
success: false,
90+
message: 'Failed to pull latest code. Check your network connection and try again.',
91+
previousVersion,
92+
steps,
93+
} satisfies UpdateResult
94+
}
95+
96+
// Step 2: Rebuild and restart via Docker Compose
97+
try {
98+
const { stdout } = await execFileAsync(
99+
'docker', ['compose', 'up', '--build', '--detach', '--no-deps', 'app'],
100+
{
101+
cwd: '/app',
102+
timeout: 600_000, // 10 minutes for build
103+
},
104+
)
105+
steps.push({
106+
step: 'Rebuild & restart',
107+
status: 'success',
108+
detail: stdout.trim(),
109+
})
110+
}
111+
catch (err: unknown) {
112+
const message = err instanceof Error ? err.message : 'Unknown error'
113+
steps.push({ step: 'Rebuild & restart', status: 'failed', detail: message })
114+
return {
115+
success: false,
116+
message: 'Failed to rebuild. Your current version is still running safely. Try running the update manually.',
117+
previousVersion,
118+
steps,
119+
} satisfies UpdateResult
120+
}
121+
122+
return {
123+
success: true,
124+
message: 'Update started successfully. The application will restart momentarily. Refresh this page in about 30 seconds.',
125+
previousVersion,
126+
steps,
127+
} satisfies UpdateResult
128+
})

server/api/updates/backup.post.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { execFile } from 'node:child_process'
2+
import { promisify } from 'node:util'
3+
4+
const execFileAsync = promisify(execFile)
5+
6+
interface BackupResult {
7+
success: boolean
8+
message: string
9+
filename?: string
10+
}
11+
12+
/**
13+
* POST /api/updates/backup
14+
*
15+
* Creates a PostgreSQL database backup before applying updates.
16+
* Backups are stored in /data/backups/ which should be mapped to a
17+
* Docker volume for persistence. Falls back to /tmp/ if the
18+
* directory cannot be created.
19+
*
20+
* Requires authentication (owner only).
21+
*/
22+
export default defineEventHandler(async (event) => {
23+
await requirePermission(event, { organization: ['delete'] })
24+
25+
const { mkdir } = await import('node:fs/promises')
26+
const backupDir = '/data/backups'
27+
let effectiveDir = backupDir
28+
try {
29+
await mkdir(backupDir, { recursive: true })
30+
}
31+
catch {
32+
// Fall back to /tmp if /data/backups is not writable (e.g. non-Docker)
33+
effectiveDir = '/tmp'
34+
}
35+
36+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
37+
const filename = `reqcore-backup-${timestamp}.sql`
38+
const backupPath = `${effectiveDir}/${filename}`
39+
40+
try {
41+
// Extract connection details from DATABASE_URL
42+
const dbUrl = new URL(env.DATABASE_URL)
43+
const host = dbUrl.hostname
44+
const port = dbUrl.port || '5432'
45+
const user = dbUrl.username
46+
const database = dbUrl.pathname.slice(1)
47+
48+
await execFileAsync(
49+
'pg_dump',
50+
['-h', host, '-p', port, '-U', user, '-d', database, '--no-owner', '--no-acl', '-f', backupPath],
51+
{
52+
timeout: 300_000,
53+
env: { ...process.env, PGPASSWORD: dbUrl.password },
54+
},
55+
)
56+
57+
return {
58+
success: true,
59+
message: `Database backup created successfully at ${backupPath}`,
60+
filename,
61+
} satisfies BackupResult
62+
}
63+
catch (err: unknown) {
64+
const message = err instanceof Error ? err.message : 'Unknown error'
65+
return {
66+
success: false,
67+
message: `Backup failed: ${message}. You can create a manual backup using: docker compose exec db pg_dump -U reqcore reqcore > backup.sql`,
68+
} satisfies BackupResult
69+
}
70+
})
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { readFile } from 'node:fs/promises'
2+
import { resolve } from 'node:path'
3+
4+
interface ChangelogEntry {
5+
title: string
6+
date: string | null
7+
version: string | null
8+
link: string | null
9+
sections: { heading: string; items: string[] }[]
10+
}
11+
12+
/**
13+
* GET /api/updates/changelog
14+
*
15+
* Parses CHANGELOG.md and returns structured changelog entries.
16+
* Requires authentication.
17+
*/
18+
export default defineEventHandler(async (event) => {
19+
await requireAuth(event)
20+
21+
const changelogPath = resolve(process.cwd(), 'CHANGELOG.md')
22+
let raw: string
23+
try {
24+
raw = await readFile(changelogPath, 'utf-8')
25+
}
26+
catch {
27+
return { entries: [], currentVersion: null }
28+
}
29+
30+
const { version: currentVersion } = await readFile(
31+
resolve(process.cwd(), 'package.json'),
32+
'utf-8',
33+
).then(JSON.parse)
34+
35+
const entries: ChangelogEntry[] = []
36+
let current: ChangelogEntry | null = null
37+
let currentSection: { heading: string; items: string[] } | null = null
38+
39+
for (const line of raw.split('\n')) {
40+
// Match ## headings (entries)
41+
const h2 = line.match(/^## \[(.+?)]\((.+?)\)\s*\((.+?)\)/)
42+
const h2Unreleased = line.match(/^## Unreleased/)
43+
const h2Date = line.match(/^## (\d{4}-\d{2}-\d{2})/)
44+
45+
if (h2 || h2Unreleased || h2Date) {
46+
if (current) entries.push(current)
47+
48+
if (h2) {
49+
current = {
50+
title: `v${h2[1]}`,
51+
version: h2[1] ?? null,
52+
date: h2[3] ?? null,
53+
link: h2[2] ?? null,
54+
sections: [],
55+
}
56+
}
57+
else if (h2Unreleased) {
58+
current = { title: 'Unreleased', version: null, date: null, link: null, sections: [] }
59+
}
60+
else if (h2Date) {
61+
current = { title: h2Date[1] ?? '', version: null, date: h2Date[1] ?? null, link: null, sections: [] }
62+
}
63+
currentSection = null
64+
continue
65+
}
66+
67+
// Match ### sub-headings (sections within an entry)
68+
const h3 = line.match(/^### (.+)/)
69+
if (h3 && current) {
70+
currentSection = { heading: h3[1] ?? '', items: [] }
71+
current.sections.push(currentSection)
72+
continue
73+
}
74+
75+
// Match list items
76+
const item = line.match(/^\s*[*-]\s+(.+)/)
77+
if (item && currentSection) {
78+
currentSection.items.push(item[1] ?? '')
79+
}
80+
}
81+
82+
if (current) entries.push(current)
83+
84+
// Only keep versioned releases + Unreleased (not bare date entries)
85+
const releases = entries.filter(e => e.version !== null || e.title === 'Unreleased')
86+
87+
// Deduplicate entries with same title
88+
const seen = new Set<string>()
89+
const unique = releases.filter((e) => {
90+
const key = `${e.title}-${e.date}`
91+
if (seen.has(key)) return false
92+
seen.add(key)
93+
return true
94+
})
95+
96+
return { entries: unique, currentVersion }
97+
})

0 commit comments

Comments
 (0)