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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/forge:mason/7201-a-repository-s-ssh-clone-address-comes-f → main 3 commits fork 2 threads · 2 unresolved

  • Merged. mason/7201-a-repository-s-ssh-clone-address-comes-f was merged into main as 2d7f916.

Review

Sign in to review this change.
5 files changed +215 −25
src/http/api.ts +2 −5
@@ -12,6 +12,7 @@ import { homeTruth } from "../system/home_truth.ts";
1212 import { upstreamFacts } from "../actions/upstream_follow.ts";
1313 import { FAMILY_WORDS } from "../actions/family_words.ts";
1414 import { showProvenance } from "../actions/provenance.ts";
15 import { sshCloneUrl } from "../ssh/command.ts";
1516 import {
1617 APP_ALREADY_YOURS,
1718 ORIGIN_UNREACHABLE,
@@ -4533,8 +4534,6 @@ export function apiRoutes(router: Router<Handler>): void {
45334534 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
45344535 const window = keyedWindow(ctx);
45354536 if (window instanceof Response) return window;
4536 const host = ctx.app.config.host ?? ctx.url.hostname;
4537 const sshPort = ctx.app.config.sshPort;
45384537 const user = ctx.auth.user;
45394538 const { pins, repos } = timePhase(ctx.request, "pins", () => {
45404539 const pins = listPinned(ctx.app, user);
@@ -4558,9 +4557,7 @@ export function apiRoutes(router: Router<Handler>): void {
45584557 defaultBranch: repo.defaultBranch,
45594558 cloneUrl: `${ctx.url.origin}/${path}.git`,
45604559 sshUrl:
4561 sshPort !== undefined && sshPort > 0
4562 ? `ssh://git@${host}:${sshPort}/${path}.git`
4563 : null,
4560 sshCloneUrl(ctx.app, repo.ownerName, repo.name) ?? null,
outdated

High (untested behaviour): sshUrl is now null on a hosted forge and takes its host from config, not from the request. No test pins either. Add a pins-API case to test/ssh-clone.test.ts (port 2222 gives the address, port 0 or appSsh set gives null).

45644561 origin: originFacts(repo),
45654562 pinnedAt: pin.createdAt,
45664563 },
src/ssh/command.ts +27 −5
@@ -13,11 +13,7 @@ export function sshCommand(app: App, login?: string): string | undefined {
1313 if (login === undefined && app.config.appSsh !== undefined) return app.config.appSsh;
1414 const port = app.config.sshPort;
1515 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);
2117 const at = login === undefined ? "" : `${login}@`;
2218 return `ssh${port === 22 ? "" : ` -p ${port}`} ${at}${host}`;
2319 }
@@ -32,3 +28,29 @@ export function sshTellCommand(app: App): string | undefined {
3228 const base = sshCommand(app);
3329 return base === undefined ? undefined : `${base} tell <words>`;
3430 }
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";
1919 import { isPinned } from "../../core/pins.ts";
2020 import { parentReleases, releasesSentence } from "../../core/parent_releases.ts";
2121 import { running } from "../../kernel/version.ts";
22 import { sshCloneUrl } from "../../ssh/command.ts";
2223 import { checkMemoryAt } from "../../apps/check-memory.ts";
2324 import { repoDir } from "../../git/repos.ts";
2425 import { forkDrift } from "../../actions/forks.ts";
@@ -556,18 +557,21 @@ function originFacts(repo: Repo): [string, string][] {
556557 ];
557558 }
558559
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 */
560564 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}`;
563570 const out: [string, string][] = [
564 ["clone (http)", `http://${host}:${f.app.config.port ?? 3000}/${path}`],
571 ["clone (http)", `${origin}/${repo.ownerName}/${repo.name}.git`],
565572 ];
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]);
571575 return out;
572576 }
573577
src/web/pages/repo.ts +5 −6
@@ -96,6 +96,7 @@ const MAX_RENDER_BYTES = 1_000_000;
9696 const FIRST_LINES = 1_000;
9797 const MAX_RENDER_LINES = 10_000;
9898
99 import { sshCloneUrl } from "../../ssh/command.ts";
99100 import { pathCrumbs, refMenu, refSetFrom, type RefSet } from "./refs.ts";
100101 import {
101102 codeLayout,
@@ -726,9 +727,7 @@ function aboutCard(
726727 drift?: ForkDrift,
727728 ): Html {
728729 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);
732731 const parent = forkParent(ctx, repo);
733732 const origin = repo.origin;
734733 // A fork made at the origin has no `forkParentId` — its parent is a
@@ -845,12 +844,12 @@ function aboutCard(
845844 <p class="mt small muted">Clone over HTTPS</p>
846845 ${copyField("clone-https", cloneUrl, "HTTPS clone address", "Copy clone URL")}
847846 ${when(
848 sshPort !== undefined,
849 () =>
847 sshUrl,
848 (url) =>
850849 html`<p class="mt small muted">Clone over SSH</p>
851850 ${copyField(
852851 "clone-ssh",
853 `ssh://git@${ctx.app.config.host ?? ctx.url.hostname}:${sshPort}/${repo.ownerName}/${repo.name}.git`,
852 url,
854853 "SSH clone address",
855854 "Copy SSH clone URL",
856855 )}`,
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 /api/v1/user/pins. Add the case, or change the header so it matches what the file tests.

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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs