···11+import { execa, type Options } from 'execa'
22+33+/**
44+ * Thin wrapper over `execa` for invoking the system `git` binary with
55+ * predictable defaults.
66+ *
77+ * - Forces non-interactive mode so a misconfigured ssh setup never hangs
88+ * waiting for a passphrase or `yes/no` prompt.
99+ * - Captures stderr so callers can produce useful error messages.
1010+ * - Adds a default 60s timeout; callers can override via `options.timeout`.
1111+ */
1212+export async function git(args: string[], options: Options = {}): Promise<{ stdout: string, stderr: string }> {
1313+ const result = await execa('git', args, {
1414+ timeout: 60_000,
1515+ ...options,
1616+ env: {
1717+ // Belt and braces against interactive prompts. `GIT_TERMINAL_PROMPT=0`
1818+ // makes git fail rather than hang if it would otherwise ask for input
1919+ // (e.g. credentials).
2020+ GIT_TERMINAL_PROMPT: '0',
2121+ // Don't pick up the running user's ssh config / known_hosts. The caller
2222+ // supplies a complete GIT_SSH_COMMAND for ssh transports.
2323+ GIT_CONFIG_NOSYSTEM: '1',
2424+ ...options.env,
2525+ },
2626+ // Buffer (default) is fine for small operations; for very large fetches
2727+ // we'd want to stream stderr instead.
2828+ reject: true,
2929+ all: true,
3030+ })
3131+ return { stdout: String(result.stdout), stderr: String(result.stderr) }
3232+}
3333+3434+/**
3535+ * Recognised remote rejection patterns from the knot when a repo no longer
3636+ * exists or our key has been revoked. Surfaces as a typed error so the
3737+ * worker can mark the mapping as terminally failed rather than retry forever.
3838+ */
3939+export class RemoteRejectedPushError extends Error {
4040+ constructor(message: string, public readonly reason: 'repo-gone' | 'auth-rejected' | 'other') {
4141+ super(message)
4242+ this.name = 'RemoteRejectedPushError'
4343+ }
4444+}
4545+4646+export function classifyPushFailure(stderr: string): RemoteRejectedPushError | null {
4747+ const lc = stderr.toLowerCase()
4848+ if (lc.includes('repository not found') || lc.includes('does not exist') || lc.includes('does not appear to be a git repository')) {
4949+ return new RemoteRejectedPushError(stderr.trim(), 'repo-gone')
5050+ }
5151+ if (lc.includes('permission denied') || lc.includes('publickey') && lc.includes('denied')) {
5252+ return new RemoteRejectedPushError(stderr.trim(), 'auth-rejected')
5353+ }
5454+ return null
5555+}
···11+import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
22+import os from 'node:os'
33+import path from 'node:path'
44+import { sql } from 'drizzle-orm'
55+import { sshKey } from '../db/schema'
66+import { useDb } from './db'
77+import { decrypt } from './encryption'
88+import { pkcs8ToOpenSshPrivate } from './ssh-keypair'
99+1010+/**
1111+ * Materialise the install's SSH private key as an OpenSSH-format file on disk
1212+ * and return:
1313+ * - the `GIT_SSH_COMMAND` string to point `git` at it
1414+ * - a `cleanup()` callback that synchronously removes the temp dir
1515+ *
1616+ * The key file lives in `os.tmpdir()` with 0600 perms, has a random filename
1717+ * (collision-resistant for concurrent worker invocations on the same instance),
1818+ * and is removed in `cleanup()`. Callers must invoke `cleanup()` in a `finally`
1919+ * — leaking the key on disk is the worst failure mode here.
2020+ *
2121+ * Host key checking: tangled knots are addressed by hostname; v1 uses
2222+ * `StrictHostKeyChecking=accept-new` (TOFU) with a per-call empty known_hosts,
2323+ * which is effectively "trust the DNS for the configured knot". A future
2424+ * commit can ship pinned host keys for the canonical knots once we know what
2525+ * those are.
2626+ */
2727+export async function loadSshCommandForInstall(installationId: number): Promise<{
2828+ gitSshCommand: string
2929+ cleanup: () => void
3030+}> {
3131+ const db = useDb()
3232+ const rows = await db.select({
3333+ privateKeyCiphertext: sshKey.privateKeyCiphertext,
3434+ privateKeyNonce: sshKey.privateKeyNonce,
3535+ })
3636+ .from(sshKey)
3737+ .where(sql`${sshKey.installationId} = ${installationId}`)
3838+ .limit(1)
3939+4040+ if (rows.length === 0) {
4141+ throw new Error(`no ssh key for installation ${installationId}`)
4242+ }
4343+ const row = rows[0]!
4444+4545+ const pem = decrypt(row.privateKeyCiphertext, row.privateKeyNonce)
4646+ const openSsh = pkcs8ToOpenSshPrivate(pem, `synchub.to/${installationId}`)
4747+4848+ // Distinct dir per call so concurrent pushes within one process don't race.
4949+ const dir = mkdtempSync(path.join(os.tmpdir(), 'synchub-ssh-'))
5050+ const keyPath = path.join(dir, 'id_ed25519')
5151+ const knownHostsPath = path.join(dir, 'known_hosts')
5252+5353+ writeFileSync(keyPath, openSsh, { mode: 0o600 })
5454+ chmodSync(keyPath, 0o600)
5555+ writeFileSync(knownHostsPath, '', { mode: 0o600 })
5656+5757+ const gitSshCommand = [
5858+ 'ssh',
5959+ '-i', shellQuote(keyPath),
6060+ '-o', `UserKnownHostsFile=${shellQuote(knownHostsPath)}`,
6161+ '-o', 'StrictHostKeyChecking=accept-new',
6262+ '-o', 'IdentitiesOnly=yes',
6363+ '-o', 'BatchMode=yes',
6464+ '-o', 'ConnectTimeout=15',
6565+ ].join(' ')
6666+6767+ return {
6868+ gitSshCommand,
6969+ cleanup: () => {
7070+ try {
7171+ rmSync(dir, { recursive: true, force: true })
7272+ }
7373+ catch {
7474+ // best-effort; the temp dir will be cleaned up on process restart.
7575+ }
7676+ },
7777+ }
7878+}
7979+8080+/** Minimal shell-quoting for paths inside GIT_SSH_COMMAND. */
8181+function shellQuote(s: string): string {
8282+ // GIT_SSH_COMMAND is split on whitespace by git, so escape spaces. We don't
8383+ // bother with full shell-quoting here because the paths we generate (in
8484+ // os.tmpdir()) won't contain quotes/backslashes; this is defense in depth.
8585+ if (!/[\s"'\\]/.test(s)) return s
8686+ return `"${s.replace(/(["\\])/g, '\\$1')}"`
8787+}