···9191 const { gitSshCommand, cleanup } = await loadSshCommandForInstall(payload.installationId)
9292 sshCleanup = cleanup
93939494- const knotUrl = `ssh://git@${row.knot}/${row.tangledRepoDid}`
9494+ const knotUrl = `ssh://git@${sshHostForKnot(row.knot)}/${row.tangledRepoDid}`
9595 const pushRefspec = lastSynced
9696 ? `--force-with-lease=${payload.ref}:${lastSynced} ${payload.after}:${payload.ref}`
9797 : `+${payload.after}:${payload.ref}`
···143143 // Escape any double-quotes inside the ref. We only support standard git ref
144144 // names which never contain quotes, but be defensive.
145145 return `"${ref.replaceAll('"', '\\"')}"`
146146+}
147147+148148+/**
149149+ * Map a knot hostname (as stored on `sh.tangled.repo`) to the SSH host we
150150+ * actually push to. For the appview-hosted knot, the HTTPS XRPC endpoint is
151151+ * `knot1.tangled.sh` (Cloudflare-fronted) but SSH lives on `tangled.org`.
152152+ * Self-hosted knots serve both on the same host (their `knot` value may
153153+ * include a `:port` suffix for non-default SSH; git URL parsing handles it).
154154+ *
155155+ * The official UI does this same mapping in
156156+ * `appview/pages/templates/repo/empty.html`. If tangled adds more
157157+ * appview-hosted knots in future this'll need updating.
158158+ */
159159+function sshHostForKnot(knot: string): string {
160160+ if (knot === 'knot1.tangled.sh') return 'tangled.org'
161161+ return knot
146162}
147163148164async function markMappingError(mappingId: number, message: string): Promise<void> {