Public: anyone can read this; the crew can change it.
A repository's SSH clone address comes from the one SSH builder: never `:0`, and absent when SSH is off or the forge is hosted #7207
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/7201-a-repository-s-ssh-clone-address-comes-f → main 3 commits fork 2 threads · 2 unresolved
5 files
changed +215
−25
src/http/api.ts +2 −5
| @@ -12,6 +12,7 @@ import { homeTruth } from "../system/home_truth.ts"; | ||
| 12 | 12 | import { upstreamFacts } from "../actions/upstream_follow.ts"; |
| 13 | 13 | import { FAMILY_WORDS } from "../actions/family_words.ts"; |
| 14 | 14 | import { showProvenance } from "../actions/provenance.ts"; |
| 15 | import { sshCloneUrl } from "../ssh/command.ts"; | |
| 15 | 16 | import { |
| 16 | 17 | APP_ALREADY_YOURS, |
| 17 | 18 | ORIGIN_UNREACHABLE, |
| @@ -4533,8 +4534,6 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 4533 | 4534 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| 4534 | 4535 | const window = keyedWindow(ctx); |
| 4535 | 4536 | if (window instanceof Response) return window; |
| 4536 | const host = ctx.app.config.host ?? ctx.url.hostname; | |
| 4537 | const sshPort = ctx.app.config.sshPort; | |
| 4538 | 4537 | const user = ctx.auth.user; |
| 4539 | 4538 | const { pins, repos } = timePhase(ctx.request, "pins", () => { |
| 4540 | 4539 | const pins = listPinned(ctx.app, user); |
| @@ -4558,9 +4557,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 4558 | 4557 | defaultBranch: repo.defaultBranch, |
| 4559 | 4558 | cloneUrl: `${ctx.url.origin}/${path}.git`, |
| 4560 | 4559 | sshUrl: |
| 4561 | sshPort !== undefined && sshPort > 0 | |
| 4562 | ? `ssh://git@${host}:${sshPort}/${path}.git` | |
| 4563 | : null, | |
| 4560 | sshCloneUrl(ctx.app, repo.ownerName, repo.name) ?? null, | |
| 4564 | 4561 | origin: originFacts(repo), |
| 4565 | 4562 | pinnedAt: pin.createdAt, |
| 4566 | 4563 | }, |
src/ssh/command.ts +27 −5
| @@ -13,11 +13,7 @@ export function sshCommand(app: App, login?: string): string | undefined { | ||
| 13 | 13 | if (login === undefined && app.config.appSsh !== undefined) return app.config.appSsh; |
| 14 | 14 | const port = app.config.sshPort; |
| 15 | 15 | if (port === undefined || port <= 0) return undefined; |
| 16 | const host = | |
| 17 | app.config.host ?? | |
| 18 | (app.config.url !== undefined | |
| 19 | ? new URL(app.config.url).hostname | |
| 20 | : "localhost"); | |
| 16 | const host = sshHost(app); | |
| 21 | 17 | const at = login === undefined ? "" : `${login}@`; |
| 22 | 18 | return `ssh${port === 22 ? "" : ` -p ${port}`} ${at}${host}`; |
| 23 | 19 | } |
| @@ -32,3 +28,29 @@ export function sshTellCommand(app: App): string | undefined { | ||
| 32 | 28 | const base = sshCommand(app); |
| 33 | 29 | return base === undefined ? undefined : `${base} tell <words>`; |
| 34 | 30 | } |
| 31 | ||
| 32 | /** | |
| 33 | * The `ssh://git@…` address that clones `owner/name` from this forge. The | |
| 34 | * port is left out when it is 22. Undefined when SSH is off, and when the | |
| 35 | * forge is hosted as an app of another: its own listener is then not | |
| 36 | * reachable, and the host's terminal command is not a git remote. | |
| 37 | */ | |
| 38 | export function sshCloneUrl( | |
| 39 | app: App, | |
| 40 | owner: string, | |
| 41 | name: string, | |
| 42 | ): string | undefined { | |
| 43 | if (app.config.appSsh !== undefined || sshCommand(app) === undefined) | |
| 44 | return undefined; | |
| 45 | const port = app.config.sshPort; | |
| 46 | return `ssh://git@${sshHost(app)}${port === 22 ? "" : `:${port}`}/${owner}/${name}.git`; | |
| 47 | } | |
| 48 | ||
| 49 | function sshHost(app: App): string { | |
| 50 | return ( | |
| 51 | app.config.host ?? | |
| 52 | (app.config.url !== undefined | |
| 53 | ? new URL(app.config.url).hostname | |
| 54 | : "localhost") | |
| 55 | ); | |
| 56 | } |
src/tui/screens/about.ts +13 −9
| @@ -19,6 +19,7 @@ import { audienceFor } from "../../core/audience.ts"; | ||
| 19 | 19 | import { isPinned } from "../../core/pins.ts"; |
| 20 | 20 | import { parentReleases, releasesSentence } from "../../core/parent_releases.ts"; |
| 21 | 21 | import { running } from "../../kernel/version.ts"; |
| 22 | import { sshCloneUrl } from "../../ssh/command.ts"; | |
| 22 | 23 | import { checkMemoryAt } from "../../apps/check-memory.ts"; |
| 23 | 24 | import { repoDir } from "../../git/repos.ts"; |
| 24 | 25 | import { forkDrift } from "../../actions/forks.ts"; |
| @@ -556,18 +557,21 @@ function originFacts(repo: Repo): [string, string][] { | ||
| 556 | 557 | ]; |
| 557 | 558 | } |
| 558 | 559 | |
| 559 | /** The two clone URLs, from the ports this forge is actually listening on. */ | |
| 560 | /** | |
| 561 | * The two clone URLs: HTTP from the forge's public address, the same one | |
| 562 | * the web page shows, and SSH from the one builder (absent when SSH is off). | |
| 563 | */ | |
| 560 | 564 | export function cloneFacts(f: Session, repo: Repo): [string, string][] { |
| 561 | const host = f.app.config.host ?? "localhost"; | |
| 562 | const path = `${repo.ownerName}/${repo.name}.git`; | |
| 565 | const config = f.app.config; | |
| 566 | const origin = | |
| 567 | config.url !== undefined | |
| 568 | ? new URL(config.url).origin | |
| 569 | : `http://${config.host ?? "localhost"}:${config.port ?? 3000}`; | |
| 563 | 570 | const out: [string, string][] = [ |
| 564 | ["clone (http)", `http://${host}:${f.app.config.port ?? 3000}/${path}`], | |
| 571 | ["clone (http)", `${origin}/${repo.ownerName}/${repo.name}.git`], | |
| 565 | 572 | ]; |
| 566 | if (f.app.config.sshPort !== undefined) | |
| 567 | out.push([ | |
| 568 | "clone (ssh)", | |
| 569 | `ssh://git@${host}:${f.app.config.sshPort}/${path}`, | |
| 570 | ]); | |
| 573 | const ssh = sshCloneUrl(f.app, repo.ownerName, repo.name); | |
| 574 | if (ssh !== undefined) out.push(["clone (ssh)", ssh]); | |
| 571 | 575 | return out; |
| 572 | 576 | } |
| 573 | 577 |
src/web/pages/repo.ts +5 −6
| @@ -96,6 +96,7 @@ const MAX_RENDER_BYTES = 1_000_000; | ||
| 96 | 96 | const FIRST_LINES = 1_000; |
| 97 | 97 | const MAX_RENDER_LINES = 10_000; |
| 98 | 98 | |
| 99 | import { sshCloneUrl } from "../../ssh/command.ts"; | |
| 99 | 100 | import { pathCrumbs, refMenu, refSetFrom, type RefSet } from "./refs.ts"; |
| 100 | 101 | import { |
| 101 | 102 | codeLayout, |
| @@ -726,9 +727,7 @@ function aboutCard( | ||
| 726 | 727 | drift?: ForkDrift, |
| 727 | 728 | ): Html { |
| 728 | 729 | const cloneUrl = `${ctx.url.origin}/${repo.ownerName}/${repo.name}.git`; |
| 729 | // An SSH listener is not built yet; when a deployment configures one, the | |
| 730 | // second clone line appears with no further change here. | |
| 731 | const sshPort = (ctx.app.config as { sshPort?: number }).sshPort; | |
| 730 | const sshUrl = sshCloneUrl(ctx.app, repo.ownerName, repo.name); | |
| 732 | 731 | const parent = forkParent(ctx, repo); |
| 733 | 732 | const origin = repo.origin; |
| 734 | 733 | // A fork made at the origin has no `forkParentId` — its parent is a |
| @@ -845,12 +844,12 @@ function aboutCard( | ||
| 845 | 844 | <p class="mt small muted">Clone over HTTPS</p> |
| 846 | 845 | ${copyField("clone-https", cloneUrl, "HTTPS clone address", "Copy clone URL")} |
| 847 | 846 | ${when( |
| 848 | sshPort !== undefined, | |
| 849 | () => | |
| 847 | sshUrl, | |
| 848 | (url) => | |
| 850 | 849 | html`<p class="mt small muted">Clone over SSH</p> |
| 851 | 850 | ${copyField( |
| 852 | 851 | "clone-ssh", |
| 853 | `ssh://git@${ctx.app.config.host ?? ctx.url.hostname}:${sshPort}/${repo.ownerName}/${repo.name}.git`, | |
| 852 | url, | |
| 854 | 853 | "SSH clone address", |
| 855 | 854 | "Copy SSH clone URL", |
| 856 | 855 | )}`, |
test/ssh-clone.test.ts added +168
| @@ -0,0 +1,168 @@ | ||
| 1 | /** | |
| 2 | * The SSH clone address comes from one builder, `sshCloneUrl`, and every | |
| 3 | * surface that prints it — the repository page's About, the terminal's | |
| 4 | * About, the pins API — shows exactly what it says: nothing when SSH is off | |
This header says the pins API is covered, but no test in this file reads |
||
| 5 | * (`sshPort` unset or `0`, as on a forge hosted as an app) or the forge is | |
| 6 | * hosted, `ssh://git@<host>:<port>/…` otherwise, and no port at 22. | |
| 7 | */ | |
| 8 | ||
| 9 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 10 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 11 | import { tmpdir } from "node:os"; | |
| 12 | import { join } from "node:path"; | |
| 13 | import { createApp, type App } from "../src/app.ts"; | |
| 14 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 15 | import { createRepo } from "../src/actions/repos.ts"; | |
| 16 | import type { Repo } from "../src/core/repos.ts"; | |
| 17 | import { pin } from "../src/core/pins.ts"; | |
| 18 | import { createSession } from "../src/core/sessions.ts"; | |
| 19 | import { repoDir } from "../src/git/repos.ts"; | |
| 20 | import { commitFiles } from "../src/git/write.ts"; | |
| 21 | import { sshCloneUrl } from "../src/ssh/command.ts"; | |
| 22 | import { cloneFacts } from "../src/tui/screens/about.ts"; | |
| 23 | import type { Session } from "../src/tui/session.ts"; | |
| 24 | import { seedAdmin } from "./helpers/admin.ts"; | |
| 25 | import { quietLogs } from "./helpers/globals.ts"; | |
| 26 | ||
| 27 | quietLogs(); | |
| 28 | ||
| 29 | describe("sshCloneUrl", () => { | |
| 30 | let dataDir: string; | |
| 31 | let app: App; | |
| 32 | ||
| 33 | beforeAll(async () => { | |
| 34 | dataDir = await mkdtemp(join(tmpdir(), "forge-sshclone-")); | |
| 35 | app = createApp({ dataDir, port: 0, host: "forge.example" }); | |
| 36 | }); | |
| 37 | ||
| 38 | afterAll(async () => { | |
| 39 | app.close(); | |
| 40 | await rm(dataDir, { recursive: true, force: true }); | |
| 41 | }); | |
| 42 | ||
| 43 | test("is undefined without a listener, or on port 0", () => { | |
| 44 | delete app.config.sshPort; | |
| 45 | expect(sshCloneUrl(app, "ada", "notes")).toBeUndefined(); | |
| 46 | app.config.sshPort = 0; | |
| 47 | expect(sshCloneUrl(app, "ada", "notes")).toBeUndefined(); | |
| 48 | }); | |
| 49 | ||
| 50 | test("names host and port, and leaves port 22 out", () => { | |
| 51 | app.config.sshPort = 2222; | |
| 52 | expect(sshCloneUrl(app, "ada", "notes")).toBe( | |
| 53 | "ssh://[email protected]:2222/ada/notes.git", | |
| 54 | ); | |
| 55 | app.config.sshPort = 22; | |
| 56 | expect(sshCloneUrl(app, "ada", "notes")).toBe( | |
| 57 | "ssh://[email protected]/ada/notes.git", | |
| 58 | ); | |
| 59 | }); | |
| 60 | ||
| 61 | test("is undefined on a hosted forge, whatever its port", () => { | |
| 62 | app.config.sshPort = 2222; | |
| 63 | app.config.appSsh = "ssh -p 2222 [email protected]"; | |
| 64 | try { | |
| 65 | expect(sshCloneUrl(app, "ada", "notes")).toBeUndefined(); | |
| 66 | } finally { | |
| 67 | delete app.config.appSsh; | |
| 68 | } | |
| 69 | }); | |
| 70 | }); | |
| 71 | ||
| 72 | describe("the clone address on the repository page, in the terminal and in the pins API", () => { | |
| 73 | let dataDir: string; | |
| 74 | let app: App; | |
| 75 | let servers: Servers; | |
| 76 | let repo: Repo; | |
| 77 | let cookie = ""; | |
| 78 | ||
| 79 | beforeAll(async () => { | |
| 80 | dataDir = await mkdtemp(join(tmpdir(), "forge-sshclone-page-")); | |
| 81 | app = createApp({ | |
| 82 | dataDir, | |
| 83 | port: 0, | |
| 84 | host: "forge.example", | |
| 85 | url: "https://forge.example", | |
| 86 | }); | |
| 87 | servers = await serve(app); | |
| 88 | const ada = await seedAdmin(app, { username: "ada", email: "[email protected]" }); | |
| 89 | if (!ada.ok) throw new Error(ada.error); | |
| 90 | const made = await createRepo(app, ada.value, { name: "notes", visibility: "public" }); | |
| 91 | if (!made.ok) throw new Error(String(made.error)); | |
| 92 | repo = made.value; | |
| 93 | pin(app.db, ada.value.id, repo.id); | |
| 94 | cookie = `forge_session=${createSession(app.db, ada.value.id).token}`; | |
| 95 | // An empty repository has no About card; one commit gives it one. | |
| 96 | const c = await commitFiles(repoDir(app.reposRoot, "ada", "notes"), { | |
| 97 | branch: "main", | |
| 98 | message: "first", | |
| 99 | files: [{ path: "README.md", content: "# notes\n" }], | |
| 100 | author: { name: "ada", email: "[email protected]" }, | |
| 101 | }); | |
| 102 | if (!c.ok) throw new Error("no commit"); | |
| 103 | }); | |
| 104 | ||
| 105 | afterAll(async () => { | |
| 106 | servers.stop(); | |
| 107 | app.close(); | |
| 108 | await rm(dataDir, { recursive: true, force: true }); | |
| 109 | }); | |
| 110 | ||
| 111 | const page = async (): Promise<string> => { | |
| 112 | const res = await fetch(`${servers.url}/ada/notes`); | |
| 113 | expect(res.status).toBe(200); | |
| 114 | return res.text(); | |
| 115 | }; | |
| 116 | const pinned = async (): Promise<string | null> => { | |
| 117 | const res = await fetch(`${servers.url}/api/v1/user/pins`, { headers: { cookie } }); | |
| 118 | expect(res.status).toBe(200); | |
| 119 | const body = (await res.json()) as { items: { sshUrl: string | null }[] }; | |
| 120 | return body.items[0]!.sshUrl; | |
| 121 | }; | |
| 122 | const terminal = (): [string, string][] => | |
| 123 | cloneFacts({ app } as unknown as Session, repo); | |
| 124 | ||
| 125 | test("with sshPort 0 neither shows an ssh line", async () => { | |
| 126 | app.config.sshPort = 0; | |
| 127 | expect(await page()).not.toContain('id="clone-ssh"'); | |
| 128 | expect(terminal().map(([k]) => k)).toEqual(["clone (http)"]); | |
| 129 | expect(await pinned()).toBeNull(); | |
| 130 | }); | |
| 131 | ||
| 132 | test("with sshPort 2222 both show the same address", async () => { | |
| 133 | app.config.sshPort = 2222; | |
| 134 | const want = "ssh://[email protected]:2222/ada/notes.git"; | |
| 135 | const html = await page(); | |
| 136 | expect(html).toMatch(/<input[^>]*\bid="clone-ssh"/); | |
| 137 | expect(html).toContain(want); | |
| 138 | expect(terminal()).toContainEqual(["clone (ssh)", want]); | |
| 139 | expect(await pinned()).toBe(want); | |
| 140 | }); | |
| 141 | ||
| 142 | test("on a hosted forge none shows an ssh address, whatever its port", async () => { | |
| 143 | app.config.sshPort = 2222; | |
| 144 | app.config.appSsh = "ssh -p 2222 [email protected]"; | |
| 145 | try { | |
| 146 | expect(await page()).not.toContain('id="clone-ssh"'); | |
| 147 | expect(terminal().map(([k]) => k)).toEqual(["clone (http)"]); | |
| 148 | expect(await pinned()).toBeNull(); | |
| 149 | } finally { | |
| 150 | delete app.config.appSsh; | |
| 151 | } | |
| 152 | }); | |
| 153 | ||
| 154 | test("with port 22 the address has no port", async () => { | |
| 155 | app.config.sshPort = 22; | |
| 156 | const want = "ssh://[email protected]/ada/notes.git"; | |
| 157 | expect(await page()).toContain(want); | |
| 158 | expect(terminal()).toContainEqual(["clone (ssh)", want]); | |
| 159 | expect(await pinned()).toBe(want); | |
| 160 | }); | |
| 161 | ||
| 162 | test("the terminal's HTTP clone is the forge's public address", () => { | |
| 163 | expect(terminal()[0]).toEqual([ | |
| 164 | "clone (http)", | |
| 165 | "https://forge.example/ada/notes.git", | |
| 166 | ]); | |
| 167 | }); | |
| 168 | }); |
new above
offline
High (untested behaviour):
sshUrlis nownullon a hosted forge and takes its host from config, not from the request. No test pins either. Add a pins-API case totest/ssh-clone.test.ts(port 2222 gives the address, port 0 orappSshset givesnull).