feat(gitsync): synka wiki-repot mot remote över SSH

- ed25519-deploy-nyckel genereras av servern (config-volymen, 0600),
  GIT_SSH_COMMAND med egen known_hosts (accept-new)
- inställningar: remote-URL, live-gren, read-only, auto-synk-intervall
- bakgrundssynk: endast fast-forward, flaggar attention vid merge-behov
- pull/push, skapa/byt gren, merge-popup (ours/theirs per fil eller allt),
  abort, reset från remote med automatisk backup-gren
- headless-konfig via config.json eller updateGitSync-mutationen
- openssh-client tillagd i runtime-imagen; mutex kring alla git-operationer

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-02 23:10:34 +02:00
parent 952ff5f951
commit e181e8e68e
11 changed files with 1506 additions and 1 deletions

View File

@@ -0,0 +1,325 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed } from 'vue'
import { gql } from '@/lib/gql'
import MergeConflictDialog from '@/components/MergeConflictDialog.vue'
const emit = defineEmits<{
(e: 'status', msg: string, isError?: boolean): void
}>()
interface GitSettings {
enabled: boolean; remoteUrl: string; liveBranch: string
readOnly: boolean; autoSyncMinutes: number; publicKey: string; keySet: boolean
}
interface GitStatus {
enabled: boolean; currentBranch: string; liveBranch: string; readOnly: boolean
ahead: number; behind: number; hasUpstream: boolean; dirty: boolean
mergeInProgress: boolean; conflicts: string[]
lastSyncAt: string; lastSyncError: string; attention: boolean
}
const settings = ref<GitSettings>({
enabled: false, remoteUrl: '', liveBranch: 'main',
readOnly: false, autoSyncMinutes: 0, publicKey: '', keySet: false,
})
const status = ref<GitStatus | null>(null)
const branches = ref<{ current: string; local: string[]; remote: string[] }>({ current: '', local: [], remote: [] })
const busy = ref(false)
const switchTarget = ref('')
const newBranchName = ref('')
const confirmReset = ref(false)
const confirmRegen = ref(false)
const showMergeDialog = ref(false)
const suggestReset = ref(false)
let poll: ReturnType<typeof setInterval> | undefined
// Remote branches that have no local counterpart yet.
const allBranches = computed(() => {
const extra = branches.value.remote.filter(b => !branches.value.local.includes(b))
return [...branches.value.local, ...extra.map(b => `${b} (remote)`)]
})
async function loadSettings() {
const d = await gql<{ gitSyncSettings: GitSettings }>(
`{ gitSyncSettings { enabled remoteUrl liveBranch readOnly autoSyncMinutes publicKey keySet } }`)
settings.value = d.gitSyncSettings
}
async function loadStatus() {
try {
const d = await gql<{ gitSyncStatus: GitStatus }>(
`{ gitSyncStatus { enabled currentBranch liveBranch readOnly ahead behind hasUpstream dirty mergeInProgress conflicts lastSyncAt lastSyncError attention } }`)
status.value = d.gitSyncStatus
if (d.gitSyncStatus.mergeInProgress && d.gitSyncStatus.conflicts.length > 0) showMergeDialog.value = true
} catch { /* repo not ready yet — non-fatal */ }
}
async function loadBranches() {
try {
const d = await gql<{ gitBranches: { current: string; local: string[]; remote: string[] } }>(
`{ gitBranches { current local remote } }`)
branches.value = d.gitBranches
switchTarget.value = d.gitBranches.current
} catch { /* non-fatal */ }
}
async function refresh() {
await Promise.all([loadStatus(), loadBranches()])
}
async function saveSettings() {
busy.value = true
try {
await gql(
`mutation UpdateGitSync($input: GitSyncInput!) { updateGitSync(input: $input) }`,
{ input: {
enabled: settings.value.enabled,
remoteUrl: settings.value.remoteUrl,
liveBranch: settings.value.liveBranch,
readOnly: settings.value.readOnly,
autoSyncMinutes: Number(settings.value.autoSyncMinutes) || 0,
} },
)
emit('status', 'Git sync settings saved.')
await loadSettings()
await refresh()
} catch (err: any) { emit('status', err.message, true) } finally { busy.value = false }
}
async function copyKey() {
await navigator.clipboard.writeText(settings.value.publicKey)
emit('status', 'Public key copied to clipboard.')
}
async function regenerateKey() {
confirmRegen.value = false
busy.value = true
try {
const d = await gql<{ gitRegenerateKey: { publicKey: string } }>(
`mutation { gitRegenerateKey { publicKey } }`)
settings.value.publicKey = d.gitRegenerateKey.publicKey
settings.value.keySet = true
emit('status', 'New keypair generated — update the deploy key on your git host.')
} catch (err: any) { emit('status', err.message, true) } finally { busy.value = false }
}
async function pull() {
busy.value = true
suggestReset.value = false
try {
const d = await gql<{ gitPull: { result: string; conflicts: string[] } }>(
`mutation { gitPull { result conflicts } }`)
if (d.gitPull.result === 'MERGE_CONFLICT') {
showMergeDialog.value = true
} else if (d.gitPull.result === 'UNRELATED_HISTORIES') {
suggestReset.value = true
} else {
emit('status', 'Pulled from remote.')
}
await refresh()
} catch (err: any) { emit('status', err.message, true) } finally { busy.value = false }
}
async function push() {
busy.value = true
try {
await gql(`mutation { gitPush }`)
emit('status', 'Pushed to remote.')
await refresh()
} catch (err: any) { emit('status', err.message, true) } finally { busy.value = false }
}
async function resolveMerge(strategy: 'ours' | 'theirs', paths?: string[]) {
busy.value = true
try {
const d = await gql<{ gitResolveMerge: { resolved: boolean; remaining: string[] } }>(
`mutation Resolve($strategy: String!, $paths: [String!]) { gitResolveMerge(strategy: $strategy, paths: $paths) { resolved remaining } }`,
{ strategy, paths: paths ?? null },
)
if (d.gitResolveMerge.resolved) {
showMergeDialog.value = false
emit('status', 'Merge resolved and committed.')
}
await refresh()
} catch (err: any) { emit('status', err.message, true) } finally { busy.value = false }
}
async function abortMerge() {
busy.value = true
try {
await gql(`mutation { gitAbortMerge }`)
showMergeDialog.value = false
emit('status', 'Merge aborted.')
await refresh()
} catch (err: any) { emit('status', err.message, true) } finally { busy.value = false }
}
async function resetFromRemote() {
confirmReset.value = false
busy.value = true
try {
const d = await gql<{ gitResetFromRemote: { backupBranch: string } }>(
`mutation { gitResetFromRemote { backupBranch } }`)
suggestReset.value = false
showMergeDialog.value = false
const backup = d.gitResetFromRemote.backupBranch
emit('status', backup ? `Reset from remote. Previous state kept in branch ${backup}.` : 'Reset from remote.')
await refresh()
} catch (err: any) { emit('status', err.message, true) } finally { busy.value = false }
}
async function createBranch() {
const name = newBranchName.value.trim()
if (!name) return
busy.value = true
try {
await gql(`mutation Create($name: String!) { gitCreateBranch(name: $name) }`, { name })
emit('status', `Created and switched to branch ${name}.`)
newBranchName.value = ''
await refresh()
} catch (err: any) { emit('status', err.message, true) } finally { busy.value = false }
}
async function switchBranch() {
const name = switchTarget.value.replace(/ \(remote\)$/, '')
if (!name || name === branches.value.current) return
busy.value = true
try {
await gql(`mutation Switch($name: String!) { gitSwitchBranch(name: $name) }`, { name })
emit('status', `Switched to branch ${name}.`)
await refresh()
} catch (err: any) { emit('status', err.message, true) } finally { busy.value = false }
}
onMounted(async () => {
try { await loadSettings() } catch { /* not initialised */ }
if (settings.value.enabled) await refresh()
poll = setInterval(() => { if (settings.value.enabled && !busy.value) loadStatus() }, 30000)
})
onUnmounted(() => { if (poll) clearInterval(poll) })
</script>
<template>
<section class="card p-6">
<div class="flex items-center justify-between border-b border-slate-200 dark:border-slate-700 pb-2 mb-4">
<h2 class="text-lg font-semibold">Git Sync (remote repository)</h2>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" v-model="settings.enabled" class="rounded border-slate-300 text-accent-600 focus:ring-accent-500" />
<span class="text-sm font-medium">Enable</span>
</label>
</div>
<p class="text-sm text-slate-500 dark:text-slate-400 mb-4">
Sync the wiki with a remote git repository over SSH. Register the public key below as a
deploy key (with write access unless read-only) on your git host.
<span v-if="status?.attention" class="font-medium text-amber-600 dark:text-amber-400">Needs attention see status below.</span>
</p>
<div v-if="settings.enabled" class="space-y-4">
<div class="grid gap-4 sm:grid-cols-2">
<div class="sm:col-span-2">
<label class="label">Remote URL (SSH)</label>
<input v-model="settings.remoteUrl" class="input font-mono text-sm" placeholder="ssh://git@git.example.com:2222/user/repo.git" />
</div>
<div>
<label class="label">Live branch</label>
<input v-model="settings.liveBranch" class="input" placeholder="main" />
<p class="text-xs text-slate-500 mt-1">The branch auto-sync keeps in sync</p>
</div>
<div>
<label class="label">Auto-sync interval (minutes, 0 = manual)</label>
<input v-model.number="settings.autoSyncMinutes" type="number" min="0" class="input" />
</div>
<div class="sm:col-span-2">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" v-model="settings.readOnly" class="rounded border-slate-300 text-accent-600 focus:ring-accent-500" />
<span class="text-sm font-medium">Read-only (pull from remote, never push)</span>
</label>
</div>
</div>
<!-- Deploy key -->
<div>
<label class="label">Deploy key (public)</label>
<div v-if="settings.keySet" class="flex gap-2 items-start">
<code class="flex-1 block text-xs font-mono bg-slate-100 dark:bg-slate-900/60 rounded-lg p-3 break-all select-all">{{ settings.publicKey }}</code>
<div class="flex flex-col gap-2">
<button class="btn-secondary text-xs" @click="copyKey">Copy</button>
<button class="btn-ghost text-xs" @click="confirmRegen = true">Regenerate</button>
</div>
</div>
<p v-else class="text-xs text-slate-500">The keypair is generated when sync is first enabled and saved.</p>
<div v-if="confirmRegen" class="mt-2 rounded-lg border border-amber-300 dark:border-amber-700 bg-amber-50/50 dark:bg-amber-900/10 p-3 text-sm flex items-center justify-between gap-2">
<span>The old key stops working immediately. Continue?</span>
<span class="flex gap-2">
<button class="btn-ghost text-xs" @click="confirmRegen = false">Cancel</button>
<button class="btn-primary text-xs" :disabled="busy" @click="regenerateKey">Regenerate key</button>
</span>
</div>
</div>
<div class="flex justify-end">
<button class="btn-primary" :disabled="busy" @click="saveSettings">Save Git Sync Config</button>
</div>
<!-- Status -->
<div v-if="status?.enabled" class="rounded-lg border border-slate-200 dark:border-slate-700 p-4 space-y-3">
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm">
<span>Branch: <code class="font-mono">{{ status.currentBranch }}</code>
<span v-if="status.currentBranch === status.liveBranch" class="ml-1 text-[10px] font-bold px-1.5 py-0.5 rounded bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400">live</span>
</span>
<span v-if="status.hasUpstream" class="text-slate-500">{{ status.ahead }} {{ status.behind }}</span>
<span v-else class="text-amber-600 dark:text-amber-400">no upstream yet</span>
<span v-if="status.dirty" class="text-amber-600 dark:text-amber-400">uncommitted changes</span>
<span v-if="status.mergeInProgress" class="text-red-600 dark:text-red-400 font-medium">merge in progress</span>
<span v-if="status.lastSyncAt" class="text-slate-400 text-xs ml-auto">last sync {{ new Date(status.lastSyncAt).toLocaleString() }}</span>
</div>
<p v-if="status.lastSyncError" class="text-xs text-red-600 dark:text-red-400 font-mono">{{ status.lastSyncError }}</p>
<div class="flex flex-wrap gap-2">
<button class="btn-secondary" :disabled="busy" @click="pull">Pull</button>
<button class="btn-secondary" :disabled="busy || settings.readOnly" :title="settings.readOnly ? 'Repository is read-only' : ''" @click="push">Push</button>
<button v-if="status.mergeInProgress" class="btn-primary" :disabled="busy" @click="showMergeDialog = true">Resolve merge</button>
<button class="btn-ghost text-red-600 dark:text-red-400 ml-auto" :disabled="busy" @click="confirmReset = true">Reset from remote</button>
</div>
<div v-if="suggestReset" class="rounded-lg border border-amber-300 dark:border-amber-700 bg-amber-50/50 dark:bg-amber-900/10 p-3 text-sm">
Local and remote histories are unrelated the safe way forward is a reset from remote
(current state is kept in a backup branch).
</div>
<div v-if="confirmReset" class="rounded-lg border border-red-300 dark:border-red-700 bg-red-50/50 dark:bg-red-900/10 p-3 text-sm flex items-center justify-between gap-2">
<span>Replace the local <code class="font-mono">{{ status.currentBranch }}</code> with the remote version? Current state is saved in a backup branch.</span>
<span class="flex gap-2 shrink-0">
<button class="btn-ghost text-xs" @click="confirmReset = false">Cancel</button>
<button class="btn-primary text-xs" :disabled="busy" @click="resetFromRemote">Reset</button>
</span>
</div>
<!-- Branches -->
<div class="flex flex-wrap items-end gap-2 pt-2 border-t border-slate-100 dark:border-slate-700/50">
<div>
<label class="label">Switch branch</label>
<div class="flex gap-2">
<select v-model="switchTarget" class="input">
<option v-for="b in allBranches" :key="b" :value="b">{{ b }}</option>
</select>
<button class="btn-secondary" :disabled="busy || switchTarget.replace(/ \(remote\)$/, '') === branches.current" @click="switchBranch">Switch</button>
</div>
</div>
<div class="ml-auto">
<label class="label">New branch</label>
<div class="flex gap-2">
<input v-model="newBranchName" class="input" placeholder="draft/my-changes" @keydown.enter.prevent="createBranch" />
<button class="btn-secondary" :disabled="busy || !newBranchName.trim()" @click="createBranch">Create</button>
</div>
</div>
</div>
</div>
</div>
<MergeConflictDialog
:is-open="showMergeDialog"
:conflicts="status?.conflicts ?? []"
:busy="busy"
@close="showMergeDialog = false"
@resolve="resolveMerge"
@abort="abortMerge"
/>
</section>
</template>

View File

@@ -0,0 +1,58 @@
<script setup lang="ts">
defineProps<{
isOpen: boolean
conflicts: string[]
busy: boolean
}>()
const emit = defineEmits<{
(e: 'close'): void
(e: 'resolve', strategy: 'ours' | 'theirs', paths?: string[]): void
(e: 'abort'): void
}>()
</script>
<template>
<transition name="fade">
<div v-if="isOpen" class="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/50 backdrop-blur-sm p-4">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-xl border border-slate-200 dark:border-slate-700 w-full max-w-lg overflow-hidden flex flex-col max-h-[80vh]">
<!-- Header -->
<div class="p-4 border-b border-slate-200 dark:border-slate-700 flex items-center justify-between">
<h3 class="text-lg font-semibold text-slate-900 dark:text-white">Merge conflicts</h3>
<button type="button" @click="emit('close')" class="text-slate-400 hover:text-slate-600 dark:hover:text-slate-200">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
<!-- Body -->
<div class="p-4 bg-slate-50 dark:bg-slate-900/50 flex-1 overflow-auto flex flex-col gap-3">
<p class="text-sm text-slate-600 dark:text-slate-400">
The pull stopped because these files were changed both locally and on the remote.
Pick which version to keep per file, or for everything at once.
</p>
<ul class="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg divide-y divide-slate-100 dark:divide-slate-700/50 overflow-y-auto">
<li v-for="file in conflicts" :key="file" class="flex items-center gap-2 px-3 py-2">
<span class="text-sm font-mono text-slate-700 dark:text-slate-300 truncate flex-1">{{ file }}</span>
<button type="button" class="btn-secondary text-xs px-2 py-1" :disabled="busy" @click="emit('resolve', 'ours', [file])">Keep local</button>
<button type="button" class="btn-secondary text-xs px-2 py-1" :disabled="busy" @click="emit('resolve', 'theirs', [file])">Take remote</button>
</li>
</ul>
</div>
<!-- Footer -->
<div class="p-4 border-t border-slate-200 dark:border-slate-700 flex flex-wrap justify-end gap-2 bg-white dark:bg-slate-800">
<button type="button" class="btn-ghost mr-auto" :disabled="busy" @click="emit('abort')">Abort merge</button>
<button type="button" class="btn-secondary" :disabled="busy" @click="emit('resolve', 'ours')">Keep all local</button>
<button type="button" class="btn-primary" :disabled="busy" @click="emit('resolve', 'theirs')">Take all remote</button>
</div>
</div>
</div>
</transition>
</template>
<style scoped>
.fade-enter-active, .fade-leave-active { transition: opacity 0.15s; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
</style>

View File

@@ -3,6 +3,7 @@ import { ref, onMounted, computed } from 'vue'
import { gql } from '@/lib/gql'
import { hashPassword } from '@/lib/crypto'
import FolderPicker from '@/components/FolderPicker.vue'
import GitSyncSection from '@/components/GitSyncSection.vue'
// ── Shared ──────────────────────────────────────────────────────────────────
const loading = ref(true)
@@ -443,6 +444,9 @@ onMounted(async () => {
</div>
</section>
<!-- Git sync -->
<GitSyncSection @status="showStatus" />
<!-- Admin password -->
<section class="card p-6">
<h2 class="text-lg font-semibold mb-4 border-b border-slate-200 dark:border-slate-700 pb-2">Your Password</h2>