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

Bring an app whole from another forge: paste its link, and it runs here #8919

Merged opened this in answer to

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

Merged

scout/forge:scout/8904-bring-an-app-whole-from-another-forge-pa → main 10 commits fork 3 threads · 3 unresolved

  • Merged. scout/8904-bring-an-app-whole-from-another-forge-pa was merged into main as 4f60ed3.

Review

Sign in to review this change.
18 files changed +863 −2
docs/design/audit.md +1
@@ -53,6 +53,7 @@ Machine routes are not pages and have no entry: `/audit.csv`,
5353 - `/board` — `pages/board.ts` (1,231 lines) · every app the viewer can see, with a wall mode · `/boards` (saved queries as panels), `/flow`, `/` · colour 1, sizes 6.
5454 - `/board/returns` — `pages/returns.ts` · what the window's dollars bought · `/settings/subscriptions` spend tables, `/settings/usage` · none seen.
5555 - `/big` — `pages/board.ts` (`bigRedirect`) · a redirect kept for old addresses into the board's wall mode · it is `/board` · none (no page of its own).
56 - `/bring` — `pages/bring.ts` · bring an app whole from another forge, with its history, as a private linked app · `/new`, the palette · none seen.
5657 - `/boards` — `pages/boards-view.ts` · saved queries as panels · `/board` · none seen.
5758 - `/feed` — `studio/home.ts` · the composer and one timeline, what home was before it became the living pipeline (#8015) · `/` · none seen.
5859 - `/boards/data` — `pages/boards.ts` · the boards' query/data surface · `/board` · none seen.
docs/features/bring.md added +15
@@ -0,0 +1,15 @@
1 # Bring an app from another forge (`/bring`)
2
3 Paste the link to an app on a forge you have connected (`/settings/origins`), and it
4 comes across: the code and history are mirrored, it is registered as a **private** app
5 and deploys at once, and you watch the first deploy on its app page. "Bring an app" is in
6 the `+` menu and the palette.
7
8 - **Link back stays.** The copy is a mirror: it keeps its origin, so improvements flow
9 to the author.
10 - **Data, variables and secrets never come.** The page lists the names in the app's
11 `.env.example` (names only, never values) and takes them in one form. Empty fields set nothing.
12 - **Left out:** a forge you have not connected (public, anonymous import) answers with a
13 link to connect it; there is no anonymous fetch yet.
14 - Code: `src/origins/bring.ts` (link, host, needs), `src/actions/bring.ts`,
15 `src/web/pages/bring.ts`. Test: `test/bring.test.ts`.
src/actions/bring.ts added +151
@@ -0,0 +1,151 @@
1 /**

No src/tui/ screen calls this. Both doors are required.

2 * Bring an app whole (#8904): paste the link to an app on another forge and
3 * it is mirrored here, registered as an app and deployed, with its link back
4 * kept (a mirror knows its origin, so improvements flow to the author).
5 *
6 * What comes: the code and its history, and how it builds and runs (its own
7 * `forge.json`). What never comes by itself: data, variables and secrets.
8 * The app lands private; what it asks for (the names in its `.env.example`)
9 * is listed and asked for in one form.
10 *
11 * The page and the terminal both call this; it is the same two steps an
12 * agent already has (`mirror` an origin repository, then enable the app).
13 */
14
15 import type { App } from "../app.ts";
16 import { getAppByRepo, slugFor, getAppBySlug, SLUG_MAX, type AppRecord } from "../core/apps.ts";
17 import { listAppEnvNames } from "../core/app_env.ts";
18 import { listOrigins } from "../core/origins.ts";
19 import { getRepoById, type Repo } from "../core/repos.ts";
20 import { isActive, type User } from "../core/users.ts";
21 import { readerFor } from "../git/read.ts";
22 import { repoDir } from "../git/repos.ts";
23 import { err, ok, type Result } from "../kernel/result.ts";
24 import { startMirror } from "../origins/mirror.ts";
25 import type { MirrorJob } from "../origins/jobs.ts";
26 import { needsOf, originOnHost, parseBringLink } from "../origins/bring.ts";
27 import { requireVerified } from "./verify.ts";
28 import { appLimitWords } from "./app_limit.ts";
29 import { enableApp, setAppEnvVar } from "./apps.ts";
30
31 export type BringError =
32 | { readonly kind: "unauthorized" }
33 | { readonly kind: "unverified" }
34 | { readonly kind: "invalid"; readonly message: string }
35 /** No connected account on that forge: sign in there first. */
36 | { readonly kind: "connect"; readonly host: string };
37
38 const LINK_WORDS = {
39 empty: "Paste the link to an app.",
40 not_a_link: "That is not a link to a repository (https://host/owner/name).",
41 not_a_repository: "That link does not name a repository: it needs the owner and the name.",
42 } as const;
43
44 /** Start bringing the app at `link`; the job is the first deploy's first half. */
45 export function startBring(
46 app: App,
47 actor: User | undefined,
48 link: string,
49 ): Result<{ job: MirrorJob; originId: string }, BringError> {
50 if (actor === undefined || !isActive(actor) || actor.kind === "agent")
51 return err({ kind: "unauthorized" });
52 const proven = requireVerified(app, actor);
53 if (!proven.ok) return err({ kind: "unverified" });
54 const parsed = parseBringLink(link);
55 if (!parsed.ok) return err({ kind: "invalid", message: LINK_WORDS[parsed.error] });
56 const origin = originOnHost(listOrigins(app.db, actor.id), parsed.value.host);
57 if (origin === undefined) return err({ kind: "connect", host: parsed.value.host });
58 const job = startMirror(app, actor, origin, parsed.value.fullName);
59 return ok({ job, originId: origin.id });
60 }
61
62 export interface Brought {
63 readonly repo: Repo;
64 readonly app: AppRecord;
65 /** Names the app asks for, with whether each is already set. */
66 readonly needs: readonly { readonly name: string; readonly set: boolean }[];
67 }
68
69 function freeSlug(app: App, name: string, owner: string): string {
70 const base = slugFor(`${name}-${owner}`).slice(0, SLUG_MAX - 3).replace(/-+$/, "");
71 for (let n = 1; n < 100; n++) {
72 const candidate = n === 1 ? base : `${base}-${n}`;
73 if (getAppBySlug(app.db, candidate) === undefined) return candidate;
74 }
75 return `${base}-${Date.now().toString(36).slice(-4)}`;
76 }
77
78 /** What a landed copy is, read only: registered or not, and what it asks for. */
79 export interface Looked {
80 readonly repo: Repo;
81 readonly app: AppRecord | undefined;
82 readonly needs: readonly { readonly name: string; readonly set: boolean }[];
83 }
84
85 type NotYet = { kind: "invalid"; message: string };
86
87 /** Read a landed copy without changing anything: safe for a GET. Only the job's owner gets here. */
88 export async function bringLook(app: App, actor: User, job: MirrorJob): Promise<Result<Looked, NotYet>> {
89 if (job.repo === undefined) return err({ kind: "invalid", message: "the copy is not made yet" });
90 const repo = getRepoById(app.db, job.repo.id);
91 if (repo === undefined || repo.ownerId !== actor.id)
92 return err({ kind: "invalid", message: "the copy is not made yet" });
93 const record = getAppByRepo(app.db, repo.id);
94 const example = await readerFor(repoDir(app.reposRoot, repo.ownerName, repo.name)).object(
95 "HEAD:.env.example",
96 );
97 const names = example.ok && example.value.type === "blob"
98 ? needsOf(new TextDecoder().decode(example.value.body.slice(0, 65_536)))
99 : [];
100 const have = new Set(record === undefined ? [] : listAppEnvNames(app.db, record.id).map((e) => e.name));
101 return ok({ repo, app: record, needs: names.map((name) => ({ name, set: have.has(name) })) });
102 }
103
104 /**
105 * Once the mirror is whole: register it as an app (private, once; asking
106 * again lands on the same app) and read what it asks for. A mutation: call
107 * it from a POST or a terminal key, never a GET.
108 */
109 export async function bringLanded(
110 app: App,
111 actor: User,
112 job: MirrorJob,
113 ): Promise<Result<Brought, NotYet>> {
114 const looked = await bringLook(app, actor, job);
115 if (!looked.ok) return looked;
116 let record = looked.value.app;
117 if (record === undefined) {
118 // Private first: the code may be public, running it for the world is a choice.
119 const enabled = enableApp(app, actor, looked.value.repo, {
120 slug: freeSlug(app, looked.value.repo.name, actor.username),
121 access: "private",
122 });
123 if (!enabled.ok)
124 return err({
125 kind: "invalid",
126 message:
127 enabled.error === "app_limit"
128 ? appLimitWords(app, actor.id)
129 : `the app could not be registered: ${enabled.error}`,
130 });
131 record = enabled.value;
132 }
133 return ok({ repo: looked.value.repo, app: record, needs: looked.value.needs });
134 }
135
136 /** The one form: set each non-empty value, for names the app asked for only. */
137 export async function setBroughtEnv(
138 app: App,
139 actor: User,
140 brought: Brought,
141 values: ReadonlyMap<string, string>,
142 ): Promise<number> {
143 let count = 0;
144 for (const { name } of brought.needs) {
145 const value = values.get(name);
146 if (value === undefined || value === "") continue;
147 const set = await setAppEnvVar(app, actor, brought.repo, name, value);
148 if (set.ok) count++;
149 }
150 return count;
151 }
src/core/reserved.ts +1
@@ -68,6 +68,7 @@ export const SYSTEM_NAMES: readonly string[] = [
6868 "deploy-to",
6969 "pipeline",
7070 "events",
71 "bring",
7172 "explore",
7273 "family",
7374 "favicon.ico",
src/http/server.ts +6
@@ -410,6 +410,7 @@ import {
410410 browseRaw,
411411 browseTree,
412412 } from "../web/pages/browse.ts";
413 import { bringEnvSubmit, bringJobPage, bringPage, bringRun, bringSubmit } from "../web/pages/bring.ts";
413414 import { pushThrough } from "../origins/push-through.ts";
414415 import { CAME_FROM_ELSEWHERE, forbiddenPage, notFoundPage, SENT_FROM_ELSEWHERE } from "../web/chrome.ts";
415416 import { targetForShortCode } from "../origins/oauth.ts";
@@ -768,6 +769,11 @@ export function buildRouter(): Router<Handler> {
768769 // is registered before /:owner/:name can claim the shape.
769770 router.add("GET", "/-/confirm", confirmPage);
770771 router.add("GET", "/new", newRepoPage);
772 router.add("GET", "/bring", bringPage);
773 router.add("POST", "/bring", bringSubmit);
774 router.add("GET", "/bring/:id/:job", bringJobPage);
775 router.add("POST", "/bring/:id/:job/run", bringRun);
776 router.add("POST", "/bring/:id/:job/env", bringEnvSubmit);
771777 router.add("GET", "/new/issue", newIssueAnywhere);
772778 router.add("GET", "/repos", myReposPage);
773779 router.add("GET", "/now/:stage", nowPage);
src/origins/bring.ts added +71
@@ -0,0 +1,71 @@
1 /**
2 * Bring an app whole: the pure half. A pasted link becomes the forge it is on
3 * and the repository it names; a connected origin is picked by host; and the
4 * variables an app asks for are read, by name only, from the `.env.example`
5 * it ships. Nothing here touches the network, the database or a value.
6 */
7
8 import type { Origin } from "./types.ts";
9
10 export interface BringLink {
11 /** `host[:port]` of the forge the link points at. */
12 readonly host: string;
13 /** `owner/name` on that forge. */
14 readonly fullName: string;
15 }
16
17 export type BringLinkError = "empty" | "not_a_link" | "not_a_repository";
18
19 const SEGMENT = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
20 const MAX_LINK = 300;
21
22 /**
23 * `https://codeberg.org/ana/notes`, `…/ana/notes.git` and deeper pages
24 * (`…/tree/main`, `…/pulls/3`) all name the same repository. Only http(s)
25 * links, with no credentials in them, are links.
26 */
27 export function parseBringLink(
28 text: string,
29 ): { ok: true; value: BringLink } | { ok: false; error: BringLinkError } {
30 const raw = text.trim();
31 if (raw === "") return { ok: false, error: "empty" };
32 if (raw.length > MAX_LINK) return { ok: false, error: "not_a_link" };
33 const url = URL.parse(/^[a-z][a-z0-9+.-]*:\/\//i.test(raw) ? raw : `https://${raw}`);
34 if (
35 url === null ||
36 (url.protocol !== "https:" && url.protocol !== "http:") ||
37 url.username !== "" ||
38 url.password !== "" ||
39 url.hostname === ""
40 )
41 return { ok: false, error: "not_a_link" };
42 const [owner, rawName] = url.pathname.split("/").filter((s) => s !== "");
43 const name = rawName?.replace(/\.git$/, "");
44 if (owner === undefined || name === undefined || !SEGMENT.test(owner) || !SEGMENT.test(name))
45 return { ok: false, error: "not_a_repository" };
46 return { ok: true, value: { host: url.host.toLowerCase(), fullName: `${owner}/${name}` } };
47 }
48
49 /** The account's connected origin on the link's host, if it has one. */
50 export function originOnHost(origins: readonly Origin[], host: string): Origin | undefined {
51 return origins.find((o) => !o.needsReauth && URL.parse(o.webUrl)?.host.toLowerCase() === host);
52 }
53
54 const NAME = /^[A-Z][A-Z0-9_]{0,63}$/;
55 const MAX_NEEDS = 40;
56
57 /**
58 * The variable names in a `.env.example`: `NAME=` lines, `export NAME=`
59 * allowed, comments and anything else ignored. Values are dropped at once —
60 * an example's values are the author's, never ours to carry.
61 */
62 export function needsOf(example: string): string[] {
63 const names: string[] = [];
64 for (const line of example.split(/\r?\n/)) {
65 const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(line);
66 const name = m?.[1];
67 if (name !== undefined && NAME.test(name) && !names.includes(name)) names.push(name);
68 if (names.length >= MAX_NEEDS) break;
69 }
70 return names;
71 }
src/tui/forge.ts +9
@@ -84,6 +84,7 @@ import { newPullScreen } from "./screens/newpull.ts";
8484 import { newForgeScreen } from "./screens/newforge.ts";
8585 import { newOrgScreen } from "./screens/neworg.ts";
8686 import { migrateScreen } from "./screens/migrate.ts";
87 import { bringScreen } from "./screens/bring.ts";
8788 import { newAppScreen } from "./screens/newapp.ts";
8889 import { newRepoScreen } from "./screens/newrepo.ts";
8990 import { repoScreen } from "./screens/repo.ts";
@@ -246,6 +247,14 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] {
246247 hint: "say what it does in a sentence; the crew starts on it",
247248 run: () => ctx.push(newAppScreen(session, ctx, blank("New app"))),
248249 });
250 // An app from another forge, as /bring does: a person's, never an agent's.
251 if (session.user !== undefined && session.user.kind !== "agent")
252 commands.push({
253 id: "bring-app",
254 title: "Bring an app",
255 hint: "paste a link from another forge; it runs here",
256 run: () => ctx.push(bringScreen(session, ctx, blank("Bring an app"))),
257 });
249258 // An organization you own, as /organizations/new and MCP `org_create` make it: a person's, never an agent's (#7260).
250259 if (session.user !== undefined && session.user.kind !== "agent")
251260 commands.push({
src/tui/screens/bring.ts added +83
@@ -0,0 +1,83 @@
1 /**
2 * "Bring an app" in the terminal: the door beside `/bring`. Paste the link to
3 * an app on another forge; it is mirrored (the same job card a mirror shows),
4 * registered as a private app once whole, and then each name its
5 * `.env.example` asks for is prompted for, masked. Empty leaves it unset.
6 */
7
8 import { bringLanded, setBroughtEnv, startBring, type BringError, type Brought } from "../../actions/bring.ts";
9 import type { MirrorJob } from "../../origins/jobs.ts";
10 import type { Ctx, Screen } from "../app/app.ts";
11 import type { Session } from "../session.ts";
12 import { promptScreen, runAsync } from "./common.ts";
13 import { showMirrorJob } from "./mirror.ts";
14
15 /** The words for a refusal, as the page says them. */
16 export function bringWords(e: BringError): string {
17 switch (e.kind) {
18 case "connect":
19 return `Sign in to ${e.host} first (connect your account there), then try again.`;
20 case "invalid":
21 return e.message;
22 case "unverified":
23 return "Verify your email first: an app runs code.";
24 case "unauthorized":
25 return "Only a person's own account brings an app.";
26 }
27 }
28
29 function askNext(f: Session, ctx: Ctx, parent: Screen, brought: Brought, asking: readonly string[]): void {
30 const [name, ...rest] = asking;
31 if (name === undefined) return;
32 ctx.push(
33 promptScreen(ctx, parent, {
34 title: "What it needs",
35 label: name,
36 hint: "Sealed, and the app redeploys. Empty leaves it unset.",
37 mask: true,
38 onSubmit: (value) => {
39 runAsync(ctx, async () => {
40 await setBroughtEnv(f.app, f.user!, brought, new Map([[name, value]]));
41 if (rest.length > 0) askNext(f, ctx, parent, brought, rest);
42 else ctx.toast("Saved. The app redeploys.", "success");
43 });
44 return undefined;
45 },
46 }),
47 );
48 }
49
50 function land(f: Session, ctx: Ctx, parent: Screen, job: MirrorJob): void {
51 runAsync(ctx, async () => {
52 const brought = await bringLanded(f.app, f.user!, job);
53 if (!brought.ok) {
54 ctx.toast(brought.error.message, "danger");
55 return;
56 }
57 const asking = brought.value.needs.filter((n) => !n.set).map((n) => n.name);
58 ctx.toast(`${job.fullName} is deploying here, private.`, "success");
59 askNext(f, ctx, parent, brought.value, asking);
60 });
61 }
62
63 export function bringScreen(f: Session, ctx: Ctx, parent: Screen): Screen {
64 return promptScreen(ctx, parent, {
65 title: "Bring an app",
66 label: "Link to the app",
67 placeholder: "https://codeberg.org/ana/notes",
68 hint: "On a forge you have connected. Its data and secrets stay behind.",
69 onSubmit: (link) => {
70 const started = startBring(f.app, f.user, link);
71 if (!started.ok) return bringWords(started.error);
72 const { job, originId } = started.value;
73 return Promise.resolve().then(() =>
74 showMirrorJob(f, ctx, job, {
75 parent,
76 originId,
77 fullName: job.fullName,
78 onLanded: () => land(f, ctx, parent, job),
79 }),
80 );
81 },
82 });
83 }
src/web/client.ts +1 −1
@@ -151,7 +151,7 @@ export const mirrorJs = /* js */ `
151151 paint(job.stage, job.detail);
152152 if (job.stage === 'done' && job.repo) {
153153 source.close();
154 location.href = '/' + job.repo.ownerName + '/' + job.repo.name;
154 location.href = list.getAttribute('data-landing') || '/' + job.repo.ownerName + '/' + job.repo.name;
155155 } else if (job.stage === 'failed') {
156156 // The page for a failed job carries the reason and a way to retry,
157157 // and does not stream — so this loads it once, and stops.
src/web/pages/apps.ts +1
@@ -422,6 +422,7 @@ function newAppForm(
422422 </div>
423423 </form>`,
424424 )}
425 <p class="muted">Already have an app on another forge? <a href="/bring">Bring it here whole</a>.</p>
425426 </div>`;
426427 }
427428
src/web/pages/bring.ts added +238
@@ -0,0 +1,238 @@
1 /**
2 * `/bring`: paste the link to an app on another forge, and it runs here.
3 *
4 * Three small pages, one per step, each working without JavaScript: the link
5 * form, the import while it happens (the same checklist a mirror shows), and
6 * the landing, which says what the app asks for and takes it in one form.
7 * The first deploy is then watched on the app's own page.
8 */
9
10 import { bringLanded, bringLook, setBroughtEnv, startBring, type BringError } from "../../actions/bring.ts";
11 import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
12 import { mirrorJobFor } from "../../actions/origins.ts";
13 import { notFoundPage } from "../chrome.ts";
14 import { readForm } from "../form.ts";
15 import type { Flash } from "../flash.ts";
16 import { html } from "../html.ts";
17 import { page, redirect } from "../layout.ts";
18 import { mirrorJs } from "../client.ts";
19 import { btn, card, field, icon } from "../ui.ts";
20 import type { Ctx } from "../../http/context.ts";
21 import { accountOnly } from "./origins.ts";
22 import { stageList } from "./browse.ts";
23
24 const NOT_COPIED = ["data", "variables", "secrets"];
25
26 function form(ctx: Ctx, value: string, problem?: Flash, status = 200): Response {
27 return page(
28 {
29 ctx,
30 title: "Bring an app",
31 ...(problem === undefined ? {} : { flash: problem }),
32 body: html`<div class="page-head"><h1>Bring an app</h1></div>
33 <div class="narrow-wide">
34 ${card(
35 html`<p class="muted small">
36 Paste the link to an app on another forge. It comes across with
37 how it builds and runs, and goes live here at its own address.
38 Its ${NOT_COPIED.join(", ")} stay behind: you are asked for
39 what it needs next.
40 </p>
41 <form method="post" action="/bring" class="stack">
42 ${field({
43 label: "Link to the app",
44 name: "link",
45 type: "url",
46 value,
47 required: true,
48 autofocus: true,
49 maxlength: 300,
50 placeholder: "https://codeberg.org/ana/notes",
51 spellcheck: false,
52 })}
53 <div class="bar">${btn("Bring it here", { variant: "primary", icon: "download" })}</div>
54 </form>`,
55 { head: html`${icon("download")}<h2>Bring it here</h2>` },
56 )}
57 </div>`,
58 },
59 status,
60 );
61 }
62
63 const danger = (message: string, link?: Flash["link"]): Flash => ({
64 message,
65 tone: "danger",
66 ...(link === undefined ? {} : { link }),
67 });
68
69 function refusal(ctx: Ctx, link: string, e: BringError): Response {
70 switch (e.kind) {
71 case "connect":
72 return form(
73 ctx,
74 link,
75 danger(`Sign in to ${e.host} first, then paste the link again.`, {
76 href: "/settings/origins",
77 label: "connect your account there",
78 }),
79 400,
80 );
81 case "invalid":
82 return form(ctx, link, danger(e.message), 400);
83 case "unverified":
84 return form(ctx, link, danger("Verify your email first: an app runs code."), 403);
85 case "unauthorized":
86 return form(ctx, link, danger("Only a person's own account brings an app."), 403);
87 }
88 }
89
90 /** GET /bring */
91 export function bringPage(ctx: Ctx): Response {
92 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
93 const denied = accountOnly(ctx);
94 if (denied) return denied;
95 const rendering = performance.now();
96 const response = form(ctx, ctx.url.searchParams.get("link")?.slice(0, 300) ?? "");
97 notePhase(ctx.request, "render", performance.now() - rendering);
98 return response;
99 }
outdated

Medium: this GET calls bringLanded, which registers and deploys the app. A GET should not mutate. Do it on a POST.

100
101 /** POST /bring */
102 export async function bringSubmit(ctx: Ctx): Promise<Response> {
103 const denied = accountOnly(ctx);
104 if (denied) return denied;
105 const data = await readForm(ctx, "admin");
106 if (data instanceof Response) return data;
107 const raw = data.get("link");
108 const link = typeof raw === "string" ? raw : "";
109 const started = startBring(ctx.app, ctx.auth.user, link);
110 if (!started.ok) return refusal(ctx, link.slice(0, 300), started.error);
111 return redirect(`/bring/${started.value.originId}/${started.value.job.id}`);
112 }
113
114 /** GET /bring/:id/:job */
115 export async function bringJobPage(ctx: Ctx): Promise<Response> {
116 const denied = accountOnly(ctx);
117 if (denied) return denied;
118 const originId = ctx.params.id ?? "";
119 const found = mirrorJobFor(ctx.app, ctx.auth.user, originId, ctx.params.job ?? "");
120 if (!found.ok) return notFoundPage(ctx);
121 const job = found.value;
122 const here = `/bring/${originId}/${job.id}`;
123
124 if (job.stage === "done") {
125 const looked = await bringLook(ctx.app, ctx.auth.user!, job);
126 if (!looked.ok) return notFoundPage(ctx);
127 const { repo, needs } = looked.value;
128 const open = `/${repo.ownerName}/${repo.name}/app`;
129 if (looked.value.app === undefined)
130 return page({
131 ctx,
132 title: `Brought ${job.fullName}`,
133 body: html`<div class="page-head"><h1>${job.fullName} is here</h1></div>
134 <div class="narrow-wide">
135 ${card(
136 html`<p>
137 The code and its history are copied, linked back to
138 <span class="mono">${job.fullName}</span> so your improvements
139 can flow to its author. Running it starts it as a private
140 app at its own address.
141 </p>
142 <form method="post" action="${here}/run" class="bar">
143 ${btn("Run it here", { variant: "primary", icon: "rocket" })}
144 </form>`,
145 { head: html`${icon("rocket")}<h2>Ready to run</h2>` },
146 )}
147 </div>`,
148 });
149 const asking = needs.filter((n) => !n.set);
150 return page({
151 ctx,
152 title: `Brought ${job.fullName}`,
153 body: html`<div class="page-head"><h1>${job.fullName} is here</h1></div>
154 <div class="narrow-wide">
155 ${card(
156 html`<p>
157 It is deploying now as a private app, linked back to
158 <span class="mono">${job.fullName}</span> so your improvements
159 can flow to its author. Watch the first deploy on its page.
160 </p>
161 <div class="bar">${btn("Watch the first deploy", { href: open, variant: "primary", icon: "rocket" })}</div>`,
162 { head: html`${icon("rocket")}<h2>Live here</h2>` },
163 )}
164 ${asking.length === 0
165 ? null
166 : card(
167 html`<p class="muted small">
168 The app lists these in its <span class="mono">.env.example</span>.
169 Nothing came with it: give what you want it to have, leave the
170 rest empty. Values are sealed and the app redeploys.
171 </p>
172 <form method="post" action="${here}/env" class="stack">
173 ${asking.map((n) =>
174 field({ label: n.name, name: `v_${n.name}`, type: "password", mono: true, autocomplete: "off", spellcheck: false }),
175 )}
176 <div class="bar">${btn("Save and continue", { variant: "primary", icon: "key" })}</div>
177 </form>`,
178 { head: html`${icon("key")}<h2>What it needs</h2>` },
179 )}
180 </div>`,
181 });
182 }
183
184 const running = job.stage !== "failed";
185 const stream = `/api/v1/user/origins/${originId}/mirror/${job.id}/stream`;
186 return page({
187 ctx,
188 title: `Bringing ${job.fullName}`,
189 body: html`<div class="page-head"><h1>Bringing ${job.fullName}</h1></div>
190 <div class="narrow-wide">
191 ${card(stageList(job, running ? stream : "", here), {
192 head: html`${icon("download")}<h2>${job.fullName}</h2>`,
193 })}
194 ${job.stage === "failed"
195 ? html`<p class="mt" role="alert">${job.error ?? "The import failed."}</p>
196 <div class="bar mt">${btn("Try another link", { href: "/bring", variant: "outline" })}</div>`
197 : html`<p class="muted small mt">
198 This keeps going if you close the page; come back to this link.
199 <noscript>Without JavaScript, <a href="${here}">reload</a> to see where it is.</noscript>
200 </p>`}
201 </div>`,
202 scriptJs: mirrorJs,
203 });
204 }
205
206 /** POST /bring/:id/:job/run: register the brought copy as an app and deploy it. */
207 export async function bringRun(ctx: Ctx): Promise<Response> {
208 const denied = accountOnly(ctx);
209 if (denied) return denied;
210 const data = await readForm(ctx, "admin");
211 if (data instanceof Response) return data;
212 const originId = ctx.params.id ?? "";
213 const found = mirrorJobFor(ctx.app, ctx.auth.user, originId, ctx.params.job ?? "");
214 if (!found.ok) return notFoundPage(ctx);
215 const brought = await bringLanded(ctx.app, ctx.auth.user!, found.value);
216 if (!brought.ok) return notFoundPage(ctx);
217 return redirect(`/bring/${originId}/${found.value.id}`);
218 }
219
220 /** POST /bring/:id/:job/env */
221 export async function bringEnvSubmit(ctx: Ctx): Promise<Response> {
222 const denied = accountOnly(ctx);
223 if (denied) return denied;
224 const data = await readForm(ctx, "admin");
225 if (data instanceof Response) return data;
226 const found = mirrorJobFor(ctx.app, ctx.auth.user, ctx.params.id ?? "", ctx.params.job ?? "");
227 if (!found.ok) return notFoundPage(ctx);
228 const brought = await bringLanded(ctx.app, ctx.auth.user!, found.value);
229 if (!brought.ok) return notFoundPage(ctx);
230 const values = new Map<string, string>();
231 for (const { name } of brought.value.needs) {
232 const v = data.get(`v_${name}`);
233 if (typeof v === "string") values.set(name, v);
234 }
235 await setBroughtEnv(ctx.app, ctx.auth.user!, brought.value, values);
236 const { repo } = brought.value;
237 return redirect(`/${repo.ownerName}/${repo.name}/app`);
238 }
src/web/pages/browse.ts +2 −1
@@ -1011,13 +1011,14 @@ const STAGES: { name: MirrorStage; label: string }[] = [
10111011 const ORDER = STAGES.map((s) => s.name);
10121012
10131013 /** The checklist. Its shape is what `mirrorJs` updates in place. */
1014 export function stageList(job: MirrorJob, streamUrl: string): Html {
1014 export function stageList(job: MirrorJob, streamUrl: string, landing = ""): Html {
10151015 return html`<ol
10161016 class="list stages"
10171017 aria-live="polite"
10181018 id="stages"
10191019 data-stream="${streamUrl}"
10201020 data-stage="${job.stage}"
1021 ${landing === "" ? null : html`data-landing="${landing}"`}
10211022 data-repo="${job.repo === undefined
10221023 ? ""
10231024 : `/${job.repo.ownerName}/${job.repo.name}`}"
test/bring.test.ts added +208
@@ -0,0 +1,208 @@
1 import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from "bun:test";
2 import { mkdtemp, rm } from "node:fs/promises";
3 import { tmpdir } from "node:os";
4 import { join } from "node:path";
5 import { createApp, type App } from "../src/app.ts";
6 import { getAppByRepo } from "../src/core/apps.ts";
7 import { listAppEnvNames } from "../src/core/app_env.ts";
8 import { createOrigin } from "../src/core/origins.ts";
9 import { getRepo } from "../src/core/repos.ts";
10 import { getUserByName } from "../src/core/users.ts";
11 import { serve, type Servers } from "../src/http/server.ts";
12 import { settled } from "../src/origins/jobs.ts";
13 import { needsOf, originOnHost, parseBringLink } from "../src/origins/bring.ts";
14 import { syncsSettled } from "../src/origins/sync.ts";
15 import { SYSTEM_NAMES } from "../src/core/reserved.ts";
16 import { newFakeState, serveFakeForge, type FakeForge } from "./fake-origin.ts";
17 import { proveAll } from "./helpers/verified.ts";
18 import { seedAdmin } from "./helpers/admin.ts";
19
20 setDefaultTimeout(60_000);
21
22 describe("the link and the needs", () => {
23 test("a link names its host and repository, however deep or dressed", () => {
24 for (const text of [
25 "https://codeberg.org/ana/notes",
26 " https://Codeberg.org/ana/notes.git ",
27 "https://codeberg.org/ana/notes/pulls/3?x=1#y",
28 "codeberg.org/ana/notes",
29 ])
30 expect(parseBringLink(text)).toEqual({
31 ok: true,
32 value: { host: "codeberg.org", fullName: "ana/notes" },
33 });
34 });
35
36 test("anything else is refused with a reason", () => {
37 expect(parseBringLink("")).toEqual({ ok: false, error: "empty" });
38 expect(parseBringLink("javascript:alert(1)")).toEqual({ ok: false, error: "not_a_link" });
39 expect(parseBringLink("ftp://x.org/a/b")).toEqual({ ok: false, error: "not_a_link" });
40 expect(parseBringLink("https://u:[email protected]/a/b")).toEqual({ ok: false, error: "not_a_link" });
41 expect(parseBringLink("https://x.org/ana")).toEqual({ ok: false, error: "not_a_repository" });
42 expect(parseBringLink("https://x.org/a/..")).toEqual({ ok: false, error: "not_a_repository" });
43 expect(parseBringLink("https://x.org/a/b".padEnd(400, "c"))).toEqual({ ok: false, error: "not_a_link" });
44 });
45
46 test("an origin is picked by host, and a spent one is not", () => {
47 const o = (webUrl: string, needsReauth: boolean) => ({ webUrl, needsReauth }) as never;
48 expect(originOnHost([o("https://a.org", false), o("https://b.org", false)], "b.org")).toBeDefined();
49 expect(originOnHost([o("https://b.org", true)], "b.org")).toBeUndefined();
50 expect(originOnHost([], "b.org")).toBeUndefined();
51 });
52
53 test("needs are names only, from NAME= lines", () => {
54 expect(
55 needsOf("# keys\nAPI_KEY=abc123\nexport DB_URL = x\nlower=1\nAPI_KEY=again\n\nPORT=3000\n"),
56 ).toEqual(["API_KEY", "DB_URL", "PORT"]);
57 });
58
59 test("bring is a page, so no account may take the name", () => {
60 expect(SYSTEM_NAMES).toContain("bring");
61 });
62 });
63
64 const LOGIN = "octo";
65 const TOKEN = "octo-secret-7089";
66 const PASSWORD = "alice-secret-7089";
67 const basic = (user: string, secret: string): string =>
68 `Basic ${Buffer.from(`${user}:${secret}`).toString("base64")}`;
69
70 describe("bringing an app whole", () => {
71 let dirs: string[] = [];
72 let app: App;
73 let gitApp: App;
74 let servers: Servers;
75 let gitServers: Servers;
76 let fake: FakeForge;
77 const alice = () => getUserByName(app.db, "alice")!;
78 const post = (path: string, body: Record<string, string>) =>
79 fetch(`${servers.url}${path}`, {
80 method: "POST",
81 redirect: "manual",
82 headers: { authorization: basic("alice", PASSWORD), "content-type": "application/x-www-form-urlencoded" },
83 body: new URLSearchParams(body).toString(),
84 });
85
86 beforeAll(async () => {
87 dirs = [await mkdtemp(join(tmpdir(), "forge-bring-")), await mkdtemp(join(tmpdir(), "forge-bring-git-"))];
88 gitApp = createApp({ dataDir: dirs[1]!, port: 0 });
89 gitServers = await serve(gitApp);
90 await seedAdmin(gitApp, { username: LOGIN, email: "[email protected]", password: TOKEN });
91 proveAll(gitApp);
92 const auth = { "content-type": "application/json", authorization: basic(LOGIN, TOKEN) };
93 await fetch(`${gitServers.url}/api/v1/repos`, {
94 method: "POST",
95 headers: auth,
96 body: JSON.stringify({ name: "widgets", visibility: "public" }),
97 });
98 const made = await fetch(`${gitServers.url}/api/v1/repos/octo/widgets/contents`, {
99 method: "POST",
100 headers: auth,
101 body: JSON.stringify({
102 branch: "main",
103 message: "an app",
104 files: [
105 { path: "forge.json", content: '{"app":{"run":"bun run index.ts"}}\n' },
106 { path: ".env.example", content: "API_KEY=changeme\nGREETING=hello\n" },
107 { path: ".env", content: "API_KEY=real-secret\n" },
108 ],
109 }),
110 });
111 expect(made.status).toBeLessThan(300);
112 const state = newFakeState("forgejo", LOGIN, TOKEN);
113 state.repos.push({
114 id: 41,
115 fullName: "octo/widgets",
116 defaultBranch: "main",
117 cloneUrl: `${gitServers.url}/octo/widgets.git`,
118 });
119 fake = serveFakeForge(state);
120 state.repos[0]!.htmlUrl = `${fake.url}/octo/widgets`;
121 app = createApp({ dataDir: dirs[0]!, port: 0 });
122 servers = await serve(app);
123 await seedAdmin(app, { username: "alice", email: "[email protected]", password: PASSWORD });
124 proveAll(app);
125 }, 120_000);
126
127 afterAll(async () => {
128 await syncsSettled();
129 fake.stop();
130 servers.stop();
131 gitServers.stop();
132 app.close();
133 gitApp.close();
134 for (const d of dirs) await rm(d, { recursive: true, force: true, maxRetries: 5 });
135 }, 120_000);
136
137 test("the new app page links to it", async () => {
138 const res = await fetch(`${servers.url}/apps/new`, { headers: { authorization: basic("alice", PASSWORD) } });
139 expect(await res.text()).toContain('href="/bring"');
140 });
141
142 test("the page asks for a link", async () => {
143 const res = await fetch(`${servers.url}/bring`, { headers: { authorization: basic("alice", PASSWORD) } });
144 expect(res.status).toBe(200);
145 expect(await res.text()).toContain('name="link"');
146 });
147
148 test("a bad link and an unconnected forge are refused in words, starting nothing", async () => {
149 const bad = await post("/bring", { link: "nonsense" });
150 expect(bad.status).toBe(400);
151 const none = await post("/bring", { link: `${fake.url}/octo/widgets` });
152 expect(none.status).toBe(400);
153 expect(await none.text()).toContain("/settings/origins");
154 expect(getRepo(app.db, "alice", "widgets")).toBeUndefined();
155 });
156
157 test("a link comes across, private, linked back, asking only by name", async () => {
158 const made = createOrigin(app.db, app.config.dataDir, {
159 userId: alice().id,
160 kind: "forgejo",
161 apiUrl: fake.apiUrl,
162 webUrl: fake.url,
163 login: LOGIN,
164 token: TOKEN,
165 });
166 if (!made.ok) throw new Error(made.error);
167
168 const started = await post("/bring", { link: `${fake.url}/octo/widgets.git` });
169 expect(started.status).toBe(303);
170 const where = started.headers.get("location")!;
171 const jobId = where.split("/").pop()!;
172 expect(where).toBe(`/bring/${made.value.id}/${jobId}`);
173 expect((await settled(jobId))?.stage).toBe("done");
174
175 // A GET only looks: the copy is here, nothing is registered or running.
176 const get = () => fetch(`${servers.url}${where}`, { headers: { authorization: basic("alice", PASSWORD) } });
177 const before = await get();
178 expect(before.status).toBe(200);
179 expect(await before.text()).toContain(`${where}/run`);
180 expect(getAppByRepo(app.db, getRepo(app.db, "alice", "widgets")!.id)).toBeUndefined();
181
182 const ran = await post(`${where}/run`, {});
183 expect(ran.status).toBe(303);
184 expect(ran.headers.get("location")).toBe(where);
185 const landed = await get();
186 expect(landed.status).toBe(200);
187 const text = await landed.text();
188 expect(text).toContain("/alice/widgets/app");
189 expect(text).toContain("API_KEY");
190 expect(text).toContain("GREETING");
191 expect(text).not.toContain("real-secret");
192
193 const repo = getRepo(app.db, "alice", "widgets")!;
194 expect(getAppByRepo(app.db, repo.id)?.access).toBe("private");
195 const link = app.db
196 .query<{ origin_full_name: string }, [string]>("SELECT origin_full_name FROM repo WHERE id = ?1")
197 .get(repo.id);
198 expect(link?.origin_full_name).toBe("octo/widgets");
199 // Nothing came by default: not the example's values, not the author's .env.
200 expect(listAppEnvNames(app.db, getAppByRepo(app.db, repo.id)!.id)).toEqual([]);
201
202 // One form sets what was filled, only for names the app asked for.
203 const saved = await post(`${where}/env`, { v_API_KEY: "mine", v_GREETING: "", v_EVIL: "x" });
204 expect(saved.status).toBe(303);
205 expect(saved.headers.get("location")).toBe("/alice/widgets/app");
206 expect(listAppEnvNames(app.db, getAppByRepo(app.db, repo.id)!.id).map((e) => e.name)).toEqual(["API_KEY"]);
207 });
208 });
test/flow-report.test.ts +1
@@ -58,6 +58,7 @@ beforeEach(async () => {
5858 bot = await mk("builder");
5959 carol = await mk("carol");
6060 app.db.query(`UPDATE user SET is_agent = 1 WHERE id = ?1`).run(bot);
61 app.db.query(`INSERT INTO agent (user_id, model, brief, budget_usd, created_by, roles) VALUES (?1, 'm', '', 1, ?2, 'builder')`).run(bot, alice);
6162 const repo = (rname: string, visibility: string) => {
6263 const rid = id();
6364 app.db.query(`INSERT INTO repo (id, owner_id, name, visibility) VALUES (?1, ?2, ?3, ?4)`).run(rid, alice, rname, visibility);
test/forge-looks.test.ts +1
@@ -45,6 +45,7 @@ const NOT_A_PAGE: Record<string, string> = {
4545 "/theme": "a mutation, answers 404 to a GET",
4646 "/tell": "answers 404 to a GET",
4747 "/asks": "redirects to /#needs-you",
48 "/bring": "a form that needs a sign-in and a link to another forge; covered by test/bring.test.ts",
4849 "/big": "redirects to /board?wall",
4950 "/pipeline": "four static words on how a change travels; /flow is the page with numbers",
5051 "/login": "redirects to /signin",
test/palette-roster.test.ts +2
@@ -63,6 +63,7 @@ test("a site-admin person is offered the whole roster", () => {
6363 ["settings", "Repository settings", "owner/name"],
6464 ["new-repo", "New repository", undefined],
6565 ["new-app", "New app", "say what it does in a sentence; the crew starts on it"],
66 ["bring-app", "Bring an app", "paste a link from another forge; it runs here"],
6667 ["new-org", "New organization", "an organization you own, for a client's apps"],
6768 ["migrate", "Migrate a repository", "bring one in from an origin or a URL, pick its branch, put a crew on it"],
6869 ["new-forge", "New forge", "spawn a client's forge and get its claim link"],
@@ -218,6 +219,7 @@ test("a member person is offered the signed-in roster without the admin entries"
218219 ["settings", "Repository settings", "owner/name"],
219220 ["new-repo", "New repository", undefined],
220221 ["new-app", "New app", "say what it does in a sentence; the crew starts on it"],
222 ["bring-app", "Bring an app", "paste a link from another forge; it runs here"],
221223 ["new-org", "New organization", "an organization you own, for a client's apps"],
222224 ["migrate", "Migrate a repository", "bring one in from an origin or a URL, pick its branch, put a crew on it"],
223225 ["new-forge", "New forge", "spawn a client's forge and get its claim link"],
test/security-floor-routes.test.ts +1
@@ -52,6 +52,7 @@ const HOOK = "needs a webhook, which the seed does not register";
5252 const UNSEEDED: Record<string, string> = {
5353 "/:owner/-/teams/:team": ORG,
5454 "/talk/:id": TALK,
55 "/bring/:id/:job": "needs a bring job of the signed-in person; the seed brings nothing",
5556 "/:owner/:name/bets/:id": BET,
5657 "/:owner/:name/looks/:env/:commit/:kind.png": LOOK,
5758 "/:owner/:name/milestones/:number/limb/:op": LIMB,
test/tui-bring.test.ts added +71
@@ -0,0 +1,71 @@
1 /** "Bring an app" in the terminal: offered to a person, never an agent; a bad link is answered in words. */
2 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createApp, type App } from "../src/app.ts";
7 import { createUser, type User } from "../src/core/users.ts";
8 import { createForgeApp, forgeCommands, Session } from "../src/tui/forge.ts";
9 import { bringScreen, bringWords } from "../src/tui/screens/bring.ts";
10 import { blank } from "../src/tui/screens/common.ts";
11 import { headlessTerminal } from "../src/tui/term/terminal.ts";
12 import { quietLogs } from "./helpers/globals.ts";
13 import { settleApp } from "./helpers/tui.ts";
14
15 quietLogs();
16
17 let dir: string;
18 let app: App;
19 let alice: User;
20
21 const identity = (user?: User) => ({
22 sshUsername: user?.username ?? "git",
23 remoteAddress: "127.0.0.1",
24 fingerprint: "SHA256:test",
25 user,
26 });
27
28 beforeAll(async () => {
29 dir = await mkdtemp(join(tmpdir(), "forge-tui-bring-"));
30 app = createApp({ dataDir: dir, url: "https://forge.test", port: 0 });
31 const made = await createUser(app.db, {
32 origin: "seed",
33 trusted: true,
34 username: "alice",
35 adminIfFirst: true,
36 email: "[email protected]",
37 password: "a-good-passphrase",
38 });
39 if (!made.ok) throw new Error(made.error);
40 alice = made.value;
41 });
42 afterAll(async () => {
43 app.db.close();
44 await rm(dir, { recursive: true, force: true });
45 });
46
47 describe("the bring screen", () => {
48 test("the palette offers it to a person, never to an agent or a guest", () => {
49 const ids = (u?: User) => forgeCommands(new Session(app, identity(u)))({} as never).map((c) => c.id);
50 expect(ids(alice)).toContain("bring-app");
51 expect(ids({ ...alice, kind: "agent" })).not.toContain("bring-app");
52 expect(ids()).not.toContain("bring-app");
53 });
54
55 test("a link that is no repository, or a forge not connected, is answered in words", async () => {
56 const terminal = headlessTerminal(120, 30);
57 const { app: tui, session } = createForgeApp(app, identity(alice), { terminal, exitHooks: false });
58 tui.start();
59 tui.ctx.push(bringScreen(session, tui.ctx, blank("Bring an app")));
60 try {
61 await settleApp(tui);
62 expect(tui.screen.toText()).toContain("Link to the app");
63 terminal.feed("nonsense\r");
64 await settleApp(tui);
65 expect(tui.screen.toText()).toContain("does not name a repository");
66 } finally {
67 tui.stop();
68 }
69 expect(bringWords({ kind: "connect", host: "codeberg.org" })).toContain("codeberg.org");
70 });
71 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs