Public: anyone can read this; the crew can change it.

A read-only work repo shows on its owner's profile as work (read-only) with its last fetch and why a fetch failed #10666

Merged opened this

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

Merged

scout/forge:scout/10522-a-read-only-work-repo-shows-on-its-owner → main 4 commits fork 3 threads · 3 unresolved

  • Merged. scout/10522-a-read-only-work-repo-shows-on-its-owner was merged into main as 87d3a43.

Review

Sign in to review this change.
8 files changed +412 −2
docs/features/origin-read-only.md +4
@@ -11,3 +11,7 @@ Tick **Read-only** when you connect an account by token (Settings → Origins, e
1111 ## Private to its owner (#10520)
1212
1313 A repository mirrored from a read-only origin is created private, whatever the origin says, and cannot be made public while the origin is read-only: `PATCH /api/v1/repos/:owner/:name` with `"visibility": "public"` answers `409` `origin_read_only` (the same through the MCP and the settings page). Being private it is left out of every list, count, search, the changelog and Talk for anyone but its owner, by the same visibility filter as any private repository. Test: `test/origin-read-only-private.test.ts`.
14
15 ## On the owner's profile (#10522)
16
17 The owner's own `/:owner` lists each read-only origin's mirrors under **work (read-only)**, each with its last fetch time; when the last fetch failed, one line says why (token refused, host unreachable, repository gone), taken from the sync result, never a stack. Server-rendered for the owner only: anyone else gets no section and no placeholder. The scheduled sync already fetches commits, branches, open and merged pulls and reviews. Mirrors also still appear in the owner's Repositories list. Test: `test/origin-read-only-profile.test.ts`.
src/origins/read-only-work.ts added +86
@@ -0,0 +1,86 @@
1 /**
2 * Work from read-only origins, as its owner sees it on their profile (#10522):
3 * every repository mirrored from an origin connected read-only, grouped by
4 * origin, each with its last fetch and, when the last one failed, one plain
5 * line saying why. Plain data out; the profile page draws it.
6 */
7
8 import type { Database } from "bun:sqlite";
9 import type { ProviderFailure } from "./types.ts";
10
11 export interface WorkRepo {
12 readonly name: string;
13 readonly fullName: string;
14 readonly lastFetchAt: string | null;
15 /** One line, empty when the last fetch worked. */
16 readonly problem: string;
17 }
18
19 export interface WorkOrigin {
20 readonly originId: string;
21 readonly webUrl: string;
22 readonly repos: readonly WorkRepo[];
23 }
24
25 /** One line a person can act on, from what a provider said. */
26 export function failureWords(failure: Pick<ProviderFailure, "error">): string {
27 switch (failure.error) {
28 case "unauthorized":
29 case "forbidden":
30 return "the token was refused: reconnect the origin with a fresh one";
31 case "not_found":
32 return "the repository is gone or no longer visible to this token";
33 case "unreachable":
34 return "the host could not be reached";
35 case "rate_limited":
36 return "the host asked us to wait; the next fetch will try again";
37 default:
38 return "the host answered something unexpected";
39 }
40 }
41
42 /** One line from git's own complaint, never its raw text or a stack. */
43 export function gitFailureWords(text: string): string {
44 if (text.includes("unusable clone URL")) return "refusing an unusable clone URL";
45 const t = text.toLowerCase();
46 if (/\b(401|403)\b|authentication|denied|invalid credentials|bad credentials/.test(t))
47 return failureWords({ error: "unauthorized" });
48 if (/\b404\b|not found|does not exist|not appear to be a git repository/.test(t))
49 return failureWords({ error: "not_found" });
50 if (/could not resolve|unable to (access|connect)|timed out|refused|unreachable|network/.test(t))
51 return failureWords({ error: "unreachable" });
52 return "git could not fetch from the host";
53 }
54
55 interface Row {
56 origin_id: string;
57 web_url: string;
58 name: string;
59 origin_full_name: string;
60 last_fetch_at: string | null;
61 fetch_error: string;
62 }
63
64 /** The user's mirrors of read-only origins; nothing for anyone else's id. */
65 export function readOnlyWork(db: Database, userId: string): WorkOrigin[] {
66 const rows = db
67 .query<Row, [string]>(
68 `SELECT o.id AS origin_id, o.web_url, r.name, r.origin_full_name, r.last_fetch_at, r.fetch_error
69 FROM repo r JOIN origin o ON o.id = r.origin_id
70 WHERE o.user_id = ?1 AND r.owner_id = ?1 AND o.read_only = 1 AND r.origin_full_name IS NOT NULL
71 ORDER BY o.id, r.name COLLATE NOCASE`,
72 )
73 .all(userId);
74 const out = new Map<string, { webUrl: string; repos: WorkRepo[] }>();
75 for (const r of rows) {
76 const group = out.get(r.origin_id) ?? { webUrl: r.web_url, repos: [] };
77 group.repos.push({
78 name: r.name,
79 fullName: r.origin_full_name,
80 lastFetchAt: r.last_fetch_at,
81 problem: r.fetch_error,
82 });
83 out.set(r.origin_id, group);
84 }
85 return [...out].map(([originId, g]) => ({ originId, ...g }));
86 }
src/origins/sync.ts +8 −1
@@ -57,6 +57,7 @@ import {
5757 importReviewStates,
5858 } from "./import.ts";
5959 import { refreshOpenPulls } from "./pull-check.ts";
60 import { failureWords, gitFailureWords } from "./read-only-work.ts";
6061 import type { Provider, ProviderFailure, RemoteIssue } from "./types.ts";
6162
6263 const log = createLog("origins");
@@ -343,6 +344,12 @@ async function syncMirrorUnguarded(
343344 } else if (described.error.error === "rate_limited") {
344345 out.rateLimited = described.error;
345346 return out;
347 } else if (described.error.error !== "invalid_response") {
348 // Bad token, host down, repository gone: say so in one line where the
349 // owner reads it, and do not go on to fetch from a host that refused.
350 out.error = failureWords(described.error);
351 recordFetch(app.db, repo.id, { at: new Date().toISOString(), error: out.error });
352 return out;
346353 }
347354
348355 // A watcher's throw is never this loop's problem (the same rule the event
@@ -376,7 +383,7 @@ async function syncMirrorUnguarded(
376383 out.error = fetched.error.message;
377384 recordFetch(app.db, repo.id, {
378385 at: runStarted,
379 error: fetched.error.message,
386 error: gitFailureWords(fetched.error.message),
380387 });
381388 } else {
382389 recordFetch(app.db, repo.id, { at: runStarted });
src/tui/forge.ts +3
@@ -17,6 +17,7 @@ import { sourceFor } from "../view/source.ts";
1717 import { fleetCommand } from "./screens/fleet.ts";
1818 import { dataCommand } from "./screens/data.ts";
1919 import { packsCommand } from "./screens/packs.ts";
20 import { workCommand } from "./screens/work.ts";
2021 import { shellsScreen } from "./screens/shells.ts";
2122 import { forkTreeScreen } from "./screens/fork_tree.ts";
2223 import { roadmapScreen } from "./screens/roadmap.ts";
@@ -281,6 +282,8 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] {
281282 ctx.push(signInScreen(session, ctx, () => ctx.invalidate())),
282283 },
283284 );
285 // The owner's read-only mirrors, as the profile's "work (read-only)" draws them.
286 if (session.user !== undefined && session.user.kind !== "agent") commands.push(workCommand(session, ctx));
284287 // An app from a sentence, as /apps/new makes it: a person's, never an agent's.
285288 if (session.user !== undefined && session.user.kind !== "agent")
286289 commands.push({
src/tui/screens/work.ts added +63
@@ -0,0 +1,63 @@
1 /**
2 * "work (read-only)" in the terminal: the same data as the owner's profile
3 * section (`readOnlyWork`, `src/origins/read-only-work.ts`) — each origin's
4 * mirrors, their last fetch and, when it failed, why in one line.
5 */
6
7 import type { Ctx, Screen } from "../app/app.ts";
8 import { binding } from "../app/keys.ts";
9 import { readOnlyWork, type WorkOrigin } from "../../origins/read-only-work.ts";
10 import { style } from "../term/style.ts";
11 import { truncate } from "../term/width.ts";
12 import { Scroll } from "./common.ts";
13 import type { Session } from "../session.ts";
14
15 /** The section as text lines. */
16 export function workLines(origins: readonly WorkOrigin[]): string[] {
17 if (origins.length === 0) return ["Nothing mirrored from a read-only origin."];
18 return origins.flatMap((o, i) => [
19 ...(i > 0 ? [""] : []),
20 URL.parse(o.webUrl)?.host ?? o.webUrl,
21 ...o.repos.map((r) =>
22 ` ${r.fullName} ${
23 r.problem !== ""
24 ? `Last fetch failed: ${r.problem}`
25 : r.lastFetchAt === null
26 ? "Not fetched yet"
27 : `Last fetch ${r.lastFetchAt}`
28 }`,
29 ),
30 ]);
31 }
32
33 export function workScreen(session: Session, ctx: Ctx): Screen {
34 const scroll = new Scroll();
35 let lines: string[] = [];
36 return {
37 title: "work (read-only)",
38 keys: [binding(["j", "k"], "scroll"), binding(["esc"], "back")],
39 render(area, s) {
40 const c = ctx.theme.c;
41 lines = session.user === undefined ? [] : workLines(readOnlyWork(session.app.db, session.user.id));
42 const from = scroll.window(area.h, lines.length);
43 for (let i = 0; i < area.h; i++) {
44 const line = lines[from + i];
45 if (line !== undefined) s.text(area.x + 1, area.y + i, truncate(line, area.w - 2), style(c.fg, c.bg), area.x + area.w);
46 }
47 },
48 key(e) {
49 if (e.key === "esc" || e.key === "q") {
50 ctx.pop();
51 return true;
52 }
53 return scroll.key(e, lines.length);
54 },
55 };
56 }
57
58 export const workCommand = (session: Session, ctx: Ctx) => ({
59 id: "work",
60 title: "work (read-only)",
61 hint: "repositories mirrored from read-only origins, last fetch and why one failed",
62 run: () => ctx.push(workScreen(session, ctx)),
63 });
src/web/pages/org.ts +41 −1
@@ -47,6 +47,7 @@ import { listReposByOwner, type Repo } from "../../core/repos.ts";
4747 import { getUserByName, hiddenFrom, type User } from "../../core/users.ts";
4848 import type { Ctx } from "../../http/context.ts";
4949 import { nameFrame, notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts";
50 import { readOnlyWork } from "../../origins/read-only-work.ts";
5051 import { notFoundPage, pageHead, signInFirst } from "../chrome.ts";
5152 import { field, readForm, type Form } from "../form.ts";
5253 import { html, when, type Html } from "../html.ts";
@@ -119,7 +120,11 @@ function repoSection(
119120 ctx.app.db,
120121 page.map((r) => r.id),
121122 );
122 const shown = page.filter((repo) => !apps.has(repo.id));
123 // The owner's read-only mirrors are drawn once, in "work (read-only)".
124 const mirrored = new Set(
125 isSelf ? readOnlyWork(ctx.app.db, owner.id).flatMap((o) => o.repos.map((r) => r.name)) : [],
126 );
127 const shown = page.filter((repo) => !apps.has(repo.id) && !mirrored.has(repo.name));
123128
124129 const lead = (repo: Repo) =>
125130 icon(repo.forkParentId !== null ? "fork" : "repo");
@@ -186,6 +191,38 @@ function repoSection(
186191 )}`;
187192 }
188193
194 /**
195 * "work (read-only)": what the owner mirrors from origins connected read-only,
196 * with its last fetch and, when it failed, why in one line. Only ever drawn
197 * for the owner's own view; nothing at all for anyone else.
198 */
199 function workSection(ctx: Ctx, owner: User): Html {
200 const origins = readOnlyWork(ctx.app.db, owner.id);
201 if (origins.length === 0) return html``;
202 return html`<section class="settings-block" id="work-read-only">
203 <h2>work (read-only)</h2>
204 ${origins.map(
205 (o) => html`<p class="small muted">${URL.parse(o.webUrl)?.host ?? o.webUrl}</p>
206 ${plainList(
207 o.repos.map((r) =>
208 row({
209 href: `/${owner.username}/${r.name}`,
outdated

Principle 1: the heading already says read-only; this explanatory line says it again. Show just the host as a muted label, or drop it.

210 lead: icon("repo"),
211 title: r.fullName,
212 nav: true,
213 meta:
214 r.problem !== ""
215 ? html`Last fetch failed: ${r.problem}`
216 : r.lastFetchAt === null
217 ? html`Not fetched yet`
218 : html`Last fetch ${time(r.lastFetchAt)}`,
219 }),
220 ),
221 )}`,
222 )}
223 </section>`;
224 }
225
189226 /**
190227 * The owner's apps, one shelf row each — the same row the home's *Your
191228 * apps* card draws (`shelfRows`, `studio/shelf.ts`), never a second copy of
@@ -382,6 +419,9 @@ export async function ownerProfile(ctx: Ctx): Promise<Response> {
382419 : null}
383420 ${apps.html}
384421 ${repos}
422 ${when(ctx.auth.user !== undefined && ctx.auth.user.id === owner.id && !isOrg, () =>
423 workSection(ctx, owner),
424 )}
385425 ${when(isOrg && accessAllows(access, "read"), () =>
386426 teamsSection(ctx, owner, canManage),
387427 )}
test/origin-read-only-profile.test.ts added +205
@@ -0,0 +1,205 @@
1 /**
2 * A read-only origin's mirrors show on their owner's profile as
3 * "work (read-only)" with the last fetch and, on failure, one plain line
4 * (#10522); and the scheduled sync lands commits, branches, open and merged
5 * pulls and reviews from a fake remote.
6 */
7 import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from "bun:test";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { createApp, type App } from "../src/app.ts";
12 import { createSession } from "../src/core/sessions.ts";
13 import { createUser } from "../src/core/users.ts";
14 import { getOriginById } from "../src/core/origins.ts";
15 import { serve, type Servers } from "../src/http/server.ts";
16 import { syncOrigin, syncsSettled } from "../src/origins/sync.ts";
17 import { workLines } from "../src/tui/screens/work.ts";
18 import { gitFailureWords, readOnlyWork } from "../src/origins/read-only-work.ts";
19 import { mirrorAndWait, newFakeState, serveFakeForge, type FakeForge } from "./fake-origin.ts";
20 import { seedAdmin } from "./helpers/admin.ts";
21 import { proveAll } from "./helpers/verified.ts";
22
23 setDefaultTimeout(60_000);
24
25 const LOGIN = "octo";
26 const TOKEN = "octo-profile-secret-5c1d";
27 const PASSWORD = "alice-login-secret";
28 const NAME = "quillmoth";
29 const FULL = `octo/${NAME}`;
30 const basic = (u: string, p: string) => `Basic ${Buffer.from(`${u}:${p}`).toString("base64")}`;
31
32 describe("work (read-only) on the owner's profile", () => {
33 const dirs: string[] = [];
34 let app: App;
35 let servers: Servers;
36 let originApp: App;
37 let originServers: Servers;
38 let fake: FakeForge;
39 let originId = "";
40 const cookies: Record<string, string> = {};
41
42 const api = (path: string, init?: RequestInit) => {
43 const headers = new Headers(init?.headers);
44 headers.set("content-type", "application/json");
45 headers.set("authorization", basic("alice", PASSWORD));
46 return fetch(`${servers.url}${path}`, { ...init, headers });
47 };
48 const as = (who: string, path: string) =>
49 fetch(`${servers.url}${path}`, {
50 headers: cookies[who] === undefined ? {} : { cookie: cookies[who]! },
51 });
52 const sweep = async () => {
53 const origin = getOriginById(app.db, originId)!;
54 await syncOrigin(app, origin);
55 await syncsSettled();
56 };
57
58 beforeAll(async () => {
59 for (const p of ["forge-rop2-", "forge-rop2-up-"]) dirs.push(await mkdtemp(join(tmpdir(), p)));
60 originApp = createApp({ dataDir: dirs[1]!, port: 0 });
61 originServers = await serve(originApp);
62 await seedAdmin(originApp, { username: LOGIN, email: "[email protected]", password: TOKEN });
63 const oh = { "content-type": "application/json", authorization: basic(LOGIN, TOKEN) };
64 await fetch(`${originServers.url}/api/v1/repos`, {
65 method: "POST",
66 headers: oh,
67 body: JSON.stringify({ name: NAME, visibility: "public" }),
68 });
69 for (const [branch, message, path] of [
70 ["main", "zephyrmarker first commit", "a.txt"],
71 ["main", "second upstream commit", "b.txt"],
72 ["feature-x", "work on the feature", "c.txt"],
73 ] as const) {
74 const r = await fetch(`${originServers.url}/api/v1/repos/${FULL}/contents`, {
75 method: "POST",
76 headers: oh,
77 body: JSON.stringify({ branch, message, files: [{ path, content: "one\n" }] }),
78 });
79 expect(r.status, message).toBeLessThan(300);
80 }
81 const state = newFakeState("github", LOGIN, TOKEN);
82 state.repos.push({
83 id: 9,
84 fullName: FULL,
85 cloneUrl: `${originServers.url}/${FULL}.git`,
86 defaultBranch: "main",
87 htmlUrl: `${originServers.url}/${FULL}`,
88 push: true,
89 });
90 const at = "2026-01-01T00:00:00.000Z";
91 state.issues.set(FULL, [
92 { id: 100, number: 1, title: "Open pull upstream", user: "mona", createdAt: at, updatedAt: at, pull: { base: "main", head: "feature-x", headRepo: FULL } },
93 { id: 101, number: 2, title: "Merged pull upstream", user: "hubot", state: "closed", createdAt: at, updatedAt: at, closedAt: at, pull: { base: "main", head: "feature-x", headRepo: FULL, mergedAt: at } },
94 ]);
95 state.reviews.set(FULL, [
96 { id: 500, issueNumber: 1, body: "inline remark", user: "mona", path: "c.txt", diffHunk: "@@ -0,0 +1 @@", line: 1, side: "RIGHT" },
97 ]);
98 state.reviewStates.set(FULL, [
99 { id: 600, issueNumber: 1, user: "hubot", state: "APPROVED", body: "ship it", submittedAt: at },
100 ]);
101 fake = serveFakeForge(state);
102
103 app = createApp({ dataDir: dirs[0]!, port: 0 });
104 servers = await serve(app);
105 const made = await seedAdmin(app, { username: "alice", email: "[email protected]", password: PASSWORD });
106 if (!made.ok) throw new Error(made.error);
107 const bob = await createUser(app.db, { username: "bob", email: "[email protected]", password: "a-good-passphrase", origin: "seed", trusted: true });
108 if (!bob.ok) throw new Error("users");
109 cookies["owner"] = `forge_session=${createSession(app.db, made.value.id).token}`;
110 cookies["stranger"] = `forge_session=${createSession(app.db, bob.value.id).token}`;
111
112 const connected = (await (
113 await api("/api/v1/user/origins", {
114 method: "POST",
115 body: JSON.stringify({ host: fake.url, kind: "github", token: TOKEN, readOnly: true }),
116 })
117 ).json()) as { id: string };
118 originId = connected.id;
119 await mirrorAndWait(
120 await api(`/api/v1/user/origins/${originId}/mirror`, { method: "POST", body: JSON.stringify({ fullName: FULL }) }),
121 );
122 await syncsSettled();
123 proveAll(app);
124 });
125
126 afterAll(async () => {
127 await syncsSettled();
128 fake.stop();
129 servers.stop();
130 originServers.stop();
131 app.close();
outdated

This three-way OR passes even if the merged pull landed as anything closed. Assert the one state the API reports for a merged pull.

132 originApp.close();
133 for (const d of dirs) await rm(d, { recursive: true, force: true });
134 });
135
136 test("a scheduled sweep lands commits, branches, open and merged pulls and reviews", async () => {
137 await sweep();
138 const commits = (await (await api(`/api/v1/repos/alice/${NAME}/commits?ref=main`)).json()) as { items?: { message: string }[] };
139 const text = JSON.stringify(commits);
140 expect(text).toContain("second upstream commit");
141 expect(text).toContain("zephyrmarker");
142 const branches = await (await api(`/api/v1/repos/alice/${NAME}/branches`)).text();
143 expect(branches).toContain("feature-x");
144 const pulls = (await (await api(`/api/v1/repos/alice/${NAME}/pulls?state=all`)).json()) as {
145 items: { number: number; state: string; mergedAt?: string }[];
146 };
147 const byNumber = new Map(pulls.items.map((p) => [p.number, p]));
148 expect(byNumber.get(1)?.state).toBe("open");
149 expect(byNumber.get(2)).toMatchObject({ state: "closed", mergedAt: "2026-01-01T00:00:00.000Z" });
150 const comment = app.db.query("SELECT body FROM comment WHERE body = 'inline remark'").get();
151 expect(comment).not.toBeNull();
152 const review = app.db.query("SELECT state FROM review").all();
153 expect(review.length).toBe(1);
154 });
155
156 test("the owner sees work (read-only) with its last fetch; nobody else sees the section", async () => {
157 const mine = await (await as("owner", "/alice")).text();
158 expect(mine).toContain("work (read-only)");
159 expect(mine).toContain(FULL);
160 expect(mine).toContain("Last fetch");
161 expect(mine).not.toContain("Last fetch failed");
162 for (const who of ["stranger", "anonymous"]) {
163 const body = await (await as(who, "/alice")).text();
164 expect(body, who).not.toContain("work (read-only)");
165 expect(body, who).not.toContain(FULL);
166 }
167 expect(readOnlyWork(app.db, "someone-else")).toEqual([]);
168 // Once: the mirror is not also listed under Repositories.
169 expect(mine.split(`href="/alice/${NAME}"`).length - 1).toBe(1);
170 });
171
172 test("the terminal screen carries the same lines", () => {
173 const alice = app.db.query<{ id: string }, []>("SELECT id FROM user WHERE username = 'alice'").get()!;
174 const text = workLines(readOnlyWork(app.db, alice.id)).join("\n");
175 expect(text).toContain(FULL);
176 expect(text).toMatch(/Last fetch /);
177 expect(workLines([])).toEqual(["Nothing mirrored from a read-only origin."]);
178 });
179
180 test("a refused token is explained in one line, not a stack", async () => {
181 fake.state.token = "rotated-away";
182 await sweep();
183 const mine = await (await as("owner", "/alice")).text();
184 expect(mine).toContain("Last fetch failed: the token was refused");
185 expect(mine).not.toMatch(/\bat \S+\.ts:\d+/);
186 fake.state.token = TOKEN;
187 });
188
189 test("a repository that is gone is explained", async () => {
190 const kept = fake.state.repos.splice(0);
191 await sweep();
192 const mine = await (await as("owner", "/alice")).text();
193 expect(mine).toContain("Last fetch failed: the repository is gone");
194 fake.state.repos.push(...kept);
195 await sweep();
196 expect(await (await as("owner", "/alice")).text()).not.toContain("Last fetch failed");
197 });
198
199 test("git's own complaints become one line", () => {
200 expect(gitFailureWords("fatal: Authentication failed for 'x'")).toContain("token was refused");
201 expect(gitFailureWords("fatal: repository 'x' not found")).toContain("gone");
202 expect(gitFailureWords("fatal: unable to access: Could not resolve host")).toContain("could not be reached");
203 expect(gitFailureWords("something\n at stack.ts:1")).not.toContain("\n");
204 });
205 });
test/palette-roster.test.ts +2
@@ -73,6 +73,7 @@ test("a site-admin person is offered the whole roster", () => {
7373 ["new-pull", "New pull request", "owner/name"],
7474 ["settings", "Repository settings", "owner/name"],
7575 ["new-repo", "New repository", undefined],
76 ["work", "work (read-only)", "repositories mirrored from read-only origins, last fetch and why one failed"],
7677 ["new-app", "New app", "say what it does in a sentence; the crew starts on it"],
7778 ["new-app-template", "New app from a template", "copy a repository you can read, as the starting files"],
7879 ["bring-app", "Bring an app", "paste a link from another forge; it runs here"],
@@ -271,6 +272,7 @@ test("a member person is offered the signed-in roster without the admin entries"
271272 ["new-pull", "New pull request", "owner/name"],
272273 ["settings", "Repository settings", "owner/name"],
273274 ["new-repo", "New repository", undefined],
275 ["work", "work (read-only)", "repositories mirrored from read-only origins, last fetch and why one failed"],
274276 ["new-app", "New app", "say what it does in a sentence; the crew starts on it"],
275277 ["new-app-template", "New app from a template", "copy a repository you can read, as the starting files"],
276278 ["bring-app", "Bring an app", "paste a link from another forge; it runs here"],

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs