feat: store backup config in DB secret instead of env var
Move backup SSH keys and repo URL from MCPD_BACKUP_REPO env var to a "backup-ssh" secret in the database. Keys are auto-generated on first init and stored back into the secret. Also fix ERR_HTTP_HEADERS_SENT crash caused by reply.send() without return in routes when onSend hook is registered. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,7 @@ export interface BackupDeps {
|
||||
interface BackupStatus {
|
||||
enabled: boolean;
|
||||
repoUrl: string | null;
|
||||
publicKey: string | null;
|
||||
gitReachable: boolean;
|
||||
lastSyncAt: string | null;
|
||||
lastPushAt: string | null;
|
||||
@@ -33,12 +34,12 @@ export function createBackupCommand(deps: BackupDeps): Command {
|
||||
if (!status.enabled) {
|
||||
deps.log('Backup: disabled');
|
||||
deps.log('');
|
||||
deps.log('To enable, set MCPD_BACKUP_REPO in your mcpd environment:');
|
||||
deps.log(' 1. Create a bare git repo (e.g. on Gitea, GitHub, or local)');
|
||||
deps.log(' 2. Add MCPD_BACKUP_REPO=<repo-url> to stack/.env');
|
||||
deps.log(' 3. Redeploy mcpd (bash deploy.sh)');
|
||||
deps.log(' 4. Run: mcpctl backup key');
|
||||
deps.log(' Add the SSH key as a deploy key (with write access) in your git host');
|
||||
deps.log('To enable, create a backup-ssh secret:');
|
||||
deps.log(' mcpctl create secret backup-ssh --data repoUrl=ssh://git@host/repo.git');
|
||||
deps.log('');
|
||||
deps.log('After creating the secret, restart mcpd. An SSH keypair will be');
|
||||
deps.log('auto-generated and stored in the secret. Run mcpctl backup to see');
|
||||
deps.log('the public key, then add it as a deploy key in your git host.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -65,6 +66,10 @@ export function createBackupCommand(deps: BackupDeps): Command {
|
||||
if (status.lastError) {
|
||||
deps.log(`Error: ${status.lastError}`);
|
||||
}
|
||||
if (status.publicKey) {
|
||||
deps.log('');
|
||||
deps.log(`SSH key: ${status.publicKey}`);
|
||||
}
|
||||
});
|
||||
|
||||
cmd
|
||||
@@ -109,16 +114,6 @@ export function createBackupCommand(deps: BackupDeps): Command {
|
||||
}
|
||||
});
|
||||
|
||||
cmd
|
||||
.command('key')
|
||||
.description('Show SSH public key for deploy key setup')
|
||||
.action(async () => {
|
||||
const { publicKey } = await deps.client.get<{ publicKey: string }>('/api/v1/backup/key');
|
||||
deps.log(publicKey);
|
||||
deps.log('');
|
||||
deps.log('Add this key as a deploy key (with write access) in your Git hosting provider.');
|
||||
});
|
||||
|
||||
// ── Restore subcommand group ──
|
||||
const restore = new Command('restore')
|
||||
.description('Restore mcpctl state from backup history');
|
||||
|
||||
@@ -73,13 +73,38 @@ describe('backup command', () => {
|
||||
expect(log).toHaveBeenCalledWith(expect.stringContaining('5 changes pending'));
|
||||
});
|
||||
|
||||
it('shows SSH public key', async () => {
|
||||
mockClient.get.mockResolvedValue({ publicKey: 'ssh-ed25519 AAAA... mcpd@mcpctl.local' });
|
||||
it('shows SSH public key in status when enabled', async () => {
|
||||
mockClient.get.mockResolvedValue({
|
||||
enabled: true,
|
||||
repoUrl: 'ssh://git@host/repo.git',
|
||||
publicKey: 'ssh-ed25519 AAAA... mcpd@mcpctl.local',
|
||||
gitReachable: true,
|
||||
lastSyncAt: null,
|
||||
lastPushAt: null,
|
||||
lastError: null,
|
||||
pendingCount: 0,
|
||||
});
|
||||
|
||||
await makeCmd().parseAsync(['key'], { from: 'user' });
|
||||
await makeCmd().parseAsync([], { from: 'user' });
|
||||
|
||||
expect(mockClient.get).toHaveBeenCalledWith('/api/v1/backup/key');
|
||||
expect(log).toHaveBeenCalledWith('ssh-ed25519 AAAA... mcpd@mcpctl.local');
|
||||
expect(log).toHaveBeenCalledWith(expect.stringContaining('ssh-ed25519 AAAA... mcpd@mcpctl.local'));
|
||||
});
|
||||
|
||||
it('shows setup instructions when disabled', async () => {
|
||||
mockClient.get.mockResolvedValue({
|
||||
enabled: false,
|
||||
repoUrl: null,
|
||||
publicKey: null,
|
||||
gitReachable: false,
|
||||
lastSyncAt: null,
|
||||
lastPushAt: null,
|
||||
lastError: null,
|
||||
pendingCount: 0,
|
||||
});
|
||||
|
||||
await makeCmd().parseAsync([], { from: 'user' });
|
||||
|
||||
expect(log).toHaveBeenCalledWith(expect.stringContaining('mcpctl create secret backup-ssh'));
|
||||
});
|
||||
|
||||
it('shows commit log', async () => {
|
||||
|
||||
@@ -367,7 +367,7 @@ async function main(): Promise<void> {
|
||||
}
|
||||
}
|
||||
if (!allowed) {
|
||||
reply.code(403).send({ error: 'Forbidden' });
|
||||
return reply.code(403).send({ error: 'Forbidden' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -395,81 +395,78 @@ async function main(): Promise<void> {
|
||||
// ── Git-based backup ──
|
||||
const gitBackup = new GitBackupService(prisma);
|
||||
|
||||
// Hook: enqueue backup after successful mutations
|
||||
if (gitBackup.enabled) {
|
||||
const kindFromSegment: Record<string, BackupKind | undefined> = {
|
||||
servers: 'server', secrets: 'secret', projects: 'project',
|
||||
templates: 'template', users: 'user', groups: 'group',
|
||||
rbac: 'rbac', prompts: 'prompt',
|
||||
};
|
||||
app.addHook('onSend', async (request, reply, payload) => {
|
||||
if (reply.statusCode >= 400) return payload;
|
||||
const method = request.method;
|
||||
if (method === 'GET' || method === 'HEAD') return payload;
|
||||
// Hook: enqueue backup after successful mutations (checks enabled at call time)
|
||||
const kindFromSegment: Record<string, BackupKind | undefined> = {
|
||||
servers: 'server', secrets: 'secret', projects: 'project',
|
||||
templates: 'template', users: 'user', groups: 'group',
|
||||
rbac: 'rbac', prompts: 'prompt',
|
||||
};
|
||||
app.addHook('onSend', async (request, reply, payload) => {
|
||||
if (!gitBackup.enabled) return payload;
|
||||
if (reply.statusCode >= 400) return payload;
|
||||
const method = request.method;
|
||||
if (method === 'GET' || method === 'HEAD') return payload;
|
||||
|
||||
const urlMatch = request.url.match(/^\/api\/v1\/([a-z-]+)(?:\/([^/?]+))?/);
|
||||
if (!urlMatch) return payload;
|
||||
const kind = kindFromSegment[urlMatch[1]!];
|
||||
if (!kind) return payload;
|
||||
const urlMatch = request.url.match(/^\/api\/v1\/([a-z-]+)(?:\/([^/?]+))?/);
|
||||
if (!urlMatch) return payload;
|
||||
const kind = kindFromSegment[urlMatch[1]!];
|
||||
if (!kind) return payload;
|
||||
|
||||
let action: 'create' | 'update' | 'delete';
|
||||
if (method === 'DELETE') action = 'delete';
|
||||
else if (method === 'POST') action = 'create';
|
||||
else action = 'update';
|
||||
let action: 'create' | 'update' | 'delete';
|
||||
if (method === 'DELETE') action = 'delete';
|
||||
else if (method === 'POST') action = 'create';
|
||||
else action = 'update';
|
||||
|
||||
// Get resource name: from URL for update/delete, from response body for create
|
||||
const nameField = kind === 'user' ? 'email' : 'name';
|
||||
let resourceName = urlMatch[2];
|
||||
if (!resourceName && typeof payload === 'string') {
|
||||
try {
|
||||
const body = JSON.parse(payload);
|
||||
resourceName = body[nameField];
|
||||
} catch { /* ignore parse errors */ }
|
||||
}
|
||||
if (!resourceName) return payload;
|
||||
// Get resource name: from URL for update/delete, from response body for create
|
||||
const nameField = kind === 'user' ? 'email' : 'name';
|
||||
let resourceName = urlMatch[2];
|
||||
if (!resourceName && typeof payload === 'string') {
|
||||
try {
|
||||
const body = JSON.parse(payload);
|
||||
resourceName = body[nameField];
|
||||
} catch { /* ignore parse errors */ }
|
||||
}
|
||||
if (!resourceName) return payload;
|
||||
|
||||
const userName = request.userId ?? 'system';
|
||||
gitBackup.enqueue(kind, resourceName, action, userName).catch((err) => {
|
||||
app.log.error({ err }, `Git backup enqueue failed for ${kind}/${resourceName}`);
|
||||
});
|
||||
return payload;
|
||||
const userName = request.userId ?? 'system';
|
||||
gitBackup.enqueue(kind, resourceName, action, userName).catch((err) => {
|
||||
app.log.error({ err }, `Git backup enqueue failed for ${kind}/${resourceName}`);
|
||||
});
|
||||
}
|
||||
return payload;
|
||||
});
|
||||
|
||||
if (gitBackup.enabled) {
|
||||
// Import callback: apply a parsed YAML doc to the DB via services
|
||||
const importResource = async (kind: BackupKind, _name: string, doc: Record<string, unknown>) => {
|
||||
const data = { ...doc };
|
||||
delete data.kind; // strip the kind field before passing to service
|
||||
switch (kind) {
|
||||
case 'server': await serverService.upsertByName(data); break;
|
||||
case 'secret': await secretService.upsertByName(data); break;
|
||||
case 'project': await projectService.upsertByName(data, 'system'); break;
|
||||
case 'user': await userService.upsertByEmail(data); break;
|
||||
case 'group': await groupService.upsertByName(data); break;
|
||||
case 'rbac': await rbacDefinitionService.upsertByName(data); break;
|
||||
case 'prompt': await promptService.upsertByName(data); break;
|
||||
case 'template': await templateService.upsertByName(data); break;
|
||||
}
|
||||
};
|
||||
const deleteResource = async (kind: BackupKind, name: string) => {
|
||||
switch (kind) {
|
||||
case 'server': await serverService.deleteByName(name); break;
|
||||
case 'secret': await secretService.deleteByName(name); break;
|
||||
case 'project': await projectService.deleteByName(name); break;
|
||||
case 'user': await userService.deleteByEmail(name); break;
|
||||
case 'group': await groupService.deleteByName(name); break;
|
||||
case 'rbac': await rbacDefinitionService.deleteByName(name); break;
|
||||
case 'prompt': await promptService.deleteByName(name); break;
|
||||
case 'template': await templateService.deleteByName(name); break;
|
||||
}
|
||||
};
|
||||
gitBackup.setCallbacks(importResource, deleteResource);
|
||||
// Init async — don't block server startup
|
||||
gitBackup.init().catch((err) => app.log.error({ err }, 'Git backup init failed'));
|
||||
}
|
||||
// Import/delete callbacks for git restore
|
||||
const importResource = async (kind: BackupKind, _name: string, doc: Record<string, unknown>) => {
|
||||
const data = { ...doc };
|
||||
delete data.kind;
|
||||
switch (kind) {
|
||||
case 'server': await serverService.upsertByName(data); break;
|
||||
case 'secret': await secretService.upsertByName(data); break;
|
||||
case 'project': await projectService.upsertByName(data, 'system'); break;
|
||||
case 'user': await userService.upsertByEmail(data); break;
|
||||
case 'group': await groupService.upsertByName(data); break;
|
||||
case 'rbac': await rbacDefinitionService.upsertByName(data); break;
|
||||
case 'prompt': await promptService.upsertByName(data); break;
|
||||
case 'template': await templateService.upsertByName(data); break;
|
||||
}
|
||||
};
|
||||
const deleteResource = async (kind: BackupKind, name: string) => {
|
||||
switch (kind) {
|
||||
case 'server': await serverService.deleteByName(name); break;
|
||||
case 'secret': await secretService.deleteByName(name); break;
|
||||
case 'project': await projectService.deleteByName(name); break;
|
||||
case 'user': await userService.deleteByEmail(name); break;
|
||||
case 'group': await groupService.deleteByName(name); break;
|
||||
case 'rbac': await rbacDefinitionService.deleteByName(name); break;
|
||||
case 'prompt': await promptService.deleteByName(name); break;
|
||||
case 'template': await templateService.deleteByName(name); break;
|
||||
}
|
||||
};
|
||||
gitBackup.setCallbacks(importResource, deleteResource);
|
||||
|
||||
// Init async — reads config from backup-ssh secret, don't block startup
|
||||
gitBackup.init().catch((err) => app.log.error({ err }, 'Git backup init failed'));
|
||||
|
||||
// Always register backup routes (status shows disabled when no repo configured)
|
||||
registerGitBackupRoutes(app, gitBackup);
|
||||
|
||||
// ── RBAC list filtering hook ──
|
||||
|
||||
@@ -20,21 +20,20 @@ export function registerAuditEventRoutes(app: FastifyInstance, service: AuditEve
|
||||
app.post('/api/v1/audit/events', async (request, reply) => {
|
||||
const body = request.body;
|
||||
if (!Array.isArray(body) || body.length === 0) {
|
||||
reply.code(400).send({ error: 'Request body must be a non-empty array of audit events' });
|
||||
return;
|
||||
return reply.code(400).send({ error: 'Request body must be a non-empty array of audit events' });
|
||||
}
|
||||
|
||||
// Basic validation
|
||||
for (const event of body) {
|
||||
const e = event as Record<string, unknown>;
|
||||
if (!e['sessionId'] || !e['projectName'] || !e['eventKind'] || !e['source'] || !e['timestamp']) {
|
||||
reply.code(400).send({ error: 'Each event requires: timestamp, sessionId, projectName, eventKind, source' });
|
||||
return;
|
||||
return reply.code(400).send({ error: 'Each event requires: timestamp, sessionId, projectName, eventKind, source' });
|
||||
}
|
||||
}
|
||||
|
||||
const count = await service.createBatch(body as AuditEventCreateInput[]);
|
||||
reply.code(201).send({ inserted: count });
|
||||
reply.code(201);
|
||||
return { inserted: count };
|
||||
});
|
||||
|
||||
// GET /api/v1/audit/events — query with filters
|
||||
|
||||
@@ -31,8 +31,7 @@ export function registerAuthRoutes(app: FastifyInstance, deps: AuthRouteDeps): v
|
||||
app.post('/api/v1/auth/bootstrap', async (request, reply) => {
|
||||
const count = await deps.userService.count();
|
||||
if (count > 0) {
|
||||
reply.code(409).send({ error: 'Users already exist. Use login instead.' });
|
||||
return;
|
||||
return reply.code(409).send({ error: 'Users already exist. Use login instead.' });
|
||||
}
|
||||
|
||||
const { email, password, name } = request.body as { email: string; password: string; name?: string };
|
||||
|
||||
@@ -2,20 +2,11 @@ import type { FastifyInstance } from 'fastify';
|
||||
import type { GitBackupService } from '../services/backup/git-backup.service.js';
|
||||
|
||||
export function registerGitBackupRoutes(app: FastifyInstance, gitBackup: GitBackupService): void {
|
||||
// GET /api/v1/backup/status — sync status
|
||||
// GET /api/v1/backup/status — sync status (includes publicKey)
|
||||
app.get('/api/v1/backup/status', async () => {
|
||||
return gitBackup.getStatus();
|
||||
});
|
||||
|
||||
// GET /api/v1/backup/key — SSH public key
|
||||
app.get('/api/v1/backup/key', async (_req, reply) => {
|
||||
const key = await gitBackup.getPublicKey();
|
||||
if (!key) {
|
||||
return reply.code(404).send({ error: 'SSH key not generated yet' });
|
||||
}
|
||||
return { publicKey: key };
|
||||
});
|
||||
|
||||
// GET /api/v1/backup/log — commit history
|
||||
app.get<{ Querystring: { limit?: string } }>('/api/v1/backup/log', async (request) => {
|
||||
const limit = parseInt(request.query.limit ?? '50', 10);
|
||||
|
||||
@@ -6,6 +6,11 @@
|
||||
*
|
||||
* Manual commits (not by mcpd) are detected and imported if they don't conflict
|
||||
* with pending DB changes. Conflicts are resolved in favor of the DB.
|
||||
*
|
||||
* Configuration is stored in a secret named "backup-ssh" in the DB.
|
||||
* The secret data contains: repoUrl, publicKey, privateKey.
|
||||
* If the secret exists with a repoUrl, backup is enabled.
|
||||
* SSH keys are auto-generated if missing from the secret.
|
||||
*/
|
||||
import { execFile as execFileCb } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
@@ -25,10 +30,12 @@ const REPO_DIR = join(BACKUP_DIR, 'repo');
|
||||
const SSH_KEY_PATH = join(BACKUP_DIR, 'id_ed25519');
|
||||
const MCPD_EMAIL = 'mcpd@mcpctl.local';
|
||||
const SYNC_INTERVAL_MS = 30_000;
|
||||
const BACKUP_SECRET_NAME = 'backup-ssh';
|
||||
|
||||
export interface BackupStatus {
|
||||
enabled: boolean;
|
||||
repoUrl: string | null;
|
||||
publicKey: string | null;
|
||||
gitReachable: boolean;
|
||||
lastSyncAt: string | null;
|
||||
lastPushAt: string | null;
|
||||
@@ -59,7 +66,8 @@ export type ImportResourceFn = (kind: BackupKind, name: string, doc: Record<stri
|
||||
export type DeleteResourceFn = (kind: BackupKind, name: string) => Promise<void>;
|
||||
|
||||
export class GitBackupService {
|
||||
private repoUrl: string | null;
|
||||
private repoUrl: string | null = null;
|
||||
private publicKey: string | null = null;
|
||||
private initialized = false;
|
||||
private gitReachable = false;
|
||||
private lastSyncAt: Date | null = null;
|
||||
@@ -73,10 +81,7 @@ export class GitBackupService {
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaClient,
|
||||
repoUrl?: string,
|
||||
) {
|
||||
this.repoUrl = repoUrl || process.env.MCPD_BACKUP_REPO || null;
|
||||
}
|
||||
) {}
|
||||
|
||||
get enabled(): boolean {
|
||||
return this.repoUrl !== null;
|
||||
@@ -88,10 +93,13 @@ export class GitBackupService {
|
||||
this.deleteResource = deleteFn;
|
||||
}
|
||||
|
||||
/** Initialize: generate SSH key, clone/init repo, initial sync. */
|
||||
/** Initialize: read config from backup-ssh secret, setup SSH key, clone/init repo. */
|
||||
async init(): Promise<void> {
|
||||
// Read config from the backup-ssh secret in the DB
|
||||
await this.loadConfigFromSecret();
|
||||
|
||||
if (!this.enabled) {
|
||||
console.log('[git-backup] Disabled (no MCPD_BACKUP_REPO configured)');
|
||||
console.log('[git-backup] Disabled (no backup-ssh secret with repoUrl)');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -151,6 +159,7 @@ export class GitBackupService {
|
||||
return {
|
||||
enabled: this.enabled,
|
||||
repoUrl: this.repoUrl,
|
||||
publicKey: this.publicKey,
|
||||
gitReachable: this.gitReachable,
|
||||
lastSyncAt: this.lastSyncAt?.toISOString() ?? null,
|
||||
lastPushAt: this.lastPushAt?.toISOString() ?? null,
|
||||
@@ -159,15 +168,6 @@ export class GitBackupService {
|
||||
};
|
||||
}
|
||||
|
||||
/** Get the SSH public key. */
|
||||
async getPublicKey(): Promise<string | null> {
|
||||
try {
|
||||
return (await readFile(`${SSH_KEY_PATH}.pub`, 'utf-8')).trim();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Get commit history. */
|
||||
async getLog(limit = 50): Promise<BackupLogEntry[]> {
|
||||
if (!this.initialized) return [];
|
||||
@@ -318,19 +318,75 @@ export class GitBackupService {
|
||||
return status.length > 0;
|
||||
}
|
||||
|
||||
// ── Config from Secret ──
|
||||
|
||||
/** Load repoUrl and SSH keys from the backup-ssh secret. */
|
||||
private async loadConfigFromSecret(): Promise<void> {
|
||||
try {
|
||||
const secret = await this.prisma.secret.findUnique({ where: { name: BACKUP_SECRET_NAME } });
|
||||
if (!secret) return;
|
||||
|
||||
const data = secret.data as Record<string, string>;
|
||||
const repoUrl = data['repoUrl'];
|
||||
if (!repoUrl) return;
|
||||
|
||||
this.repoUrl = repoUrl;
|
||||
this.publicKey = data['publicKey'] ?? null;
|
||||
} catch (err) {
|
||||
console.error(`[git-backup] Failed to read ${BACKUP_SECRET_NAME} secret: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── SSH Key ──
|
||||
|
||||
/** Ensure SSH key exists on disk (for git operations) and in the secret. */
|
||||
private async ensureSshKey(): Promise<void> {
|
||||
// Try to load key from secret first
|
||||
const secret = await this.prisma.secret.findUnique({ where: { name: BACKUP_SECRET_NAME } });
|
||||
const data = (secret?.data ?? {}) as Record<string, string>;
|
||||
|
||||
if (data['privateKey'] && data['publicKey']) {
|
||||
// Write keys from secret to disk for git SSH
|
||||
await writeFile(SSH_KEY_PATH, data['privateKey'] + '\n', { mode: 0o600 });
|
||||
await writeFile(`${SSH_KEY_PATH}.pub`, data['publicKey'] + '\n', { mode: 0o644 });
|
||||
this.publicKey = data['publicKey'];
|
||||
console.log('[git-backup] SSH key loaded from secret');
|
||||
return;
|
||||
}
|
||||
|
||||
// No keys in secret — check disk (migration from old setup)
|
||||
try {
|
||||
await access(SSH_KEY_PATH);
|
||||
console.log('[git-backup] SSH key exists');
|
||||
} catch {
|
||||
console.log('[git-backup] Generating SSH key...');
|
||||
await execFile('ssh-keygen', ['-t', 'ed25519', '-f', SSH_KEY_PATH, '-N', '', '-C', MCPD_EMAIL], {
|
||||
timeout: 10_000,
|
||||
const privateKey = (await readFile(SSH_KEY_PATH, 'utf-8')).trim();
|
||||
const pubKey = (await readFile(`${SSH_KEY_PATH}.pub`, 'utf-8')).trim();
|
||||
// Store keys back into the secret
|
||||
await this.prisma.secret.update({
|
||||
where: { name: BACKUP_SECRET_NAME },
|
||||
data: { data: { ...data, privateKey, publicKey: pubKey } },
|
||||
});
|
||||
console.log('[git-backup] SSH key generated');
|
||||
this.publicKey = pubKey;
|
||||
console.log('[git-backup] SSH key migrated from disk to secret');
|
||||
return;
|
||||
} catch {
|
||||
// No key on disk either
|
||||
}
|
||||
|
||||
// Generate new keypair
|
||||
console.log('[git-backup] Generating SSH key...');
|
||||
await execFile('ssh-keygen', ['-t', 'ed25519', '-f', SSH_KEY_PATH, '-N', '', '-C', MCPD_EMAIL], {
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
const privateKey = (await readFile(SSH_KEY_PATH, 'utf-8')).trim();
|
||||
const pubKey = (await readFile(`${SSH_KEY_PATH}.pub`, 'utf-8')).trim();
|
||||
|
||||
// Store in secret
|
||||
await this.prisma.secret.update({
|
||||
where: { name: BACKUP_SECRET_NAME },
|
||||
data: { data: { ...data, privateKey, publicKey: pubKey } },
|
||||
});
|
||||
this.publicKey = pubKey;
|
||||
console.log('[git-backup] SSH key generated and stored in secret');
|
||||
}
|
||||
|
||||
// ── Repo Init ──
|
||||
|
||||
Reference in New Issue
Block a user