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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/8904-bring-an-app-whole-from-another-forge-pa → main 10 commits fork 3 threads · 3 unresolved
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`, | ||
| 53 | 53 | - `/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. |
| 54 | 54 | - `/board/returns` — `pages/returns.ts` · what the window's dollars bought · `/settings/subscriptions` spend tables, `/settings/usage` · none seen. |
| 55 | 55 | - `/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. | |
| 56 | 57 | - `/boards` — `pages/boards-view.ts` · saved queries as panels · `/board` · none seen. |
| 57 | 58 | - `/feed` — `studio/home.ts` · the composer and one timeline, what home was before it became the living pipeline (#8015) · `/` · none seen. |
| 58 | 59 | - `/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 | /** | |
| 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[] = [ | ||
| 68 | 68 | "deploy-to", |
| 69 | 69 | "pipeline", |
| 70 | 70 | "events", |
| 71 | "bring", | |
| 71 | 72 | "explore", |
| 72 | 73 | "family", |
| 73 | 74 | "favicon.ico", |
src/http/server.ts +6
| @@ -410,6 +410,7 @@ import { | ||
| 410 | 410 | browseRaw, |
| 411 | 411 | browseTree, |
| 412 | 412 | } from "../web/pages/browse.ts"; |
| 413 | import { bringEnvSubmit, bringJobPage, bringPage, bringRun, bringSubmit } from "../web/pages/bring.ts"; | |
| 413 | 414 | import { pushThrough } from "../origins/push-through.ts"; |
| 414 | 415 | import { CAME_FROM_ELSEWHERE, forbiddenPage, notFoundPage, SENT_FROM_ELSEWHERE } from "../web/chrome.ts"; |
| 415 | 416 | import { targetForShortCode } from "../origins/oauth.ts"; |
| @@ -768,6 +769,11 @@ export function buildRouter(): Router<Handler> { | ||
| 768 | 769 | // is registered before /:owner/:name can claim the shape. |
| 769 | 770 | router.add("GET", "/-/confirm", confirmPage); |
| 770 | 771 | 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); | |
| 771 | 777 | router.add("GET", "/new/issue", newIssueAnywhere); |
| 772 | 778 | router.add("GET", "/repos", myReposPage); |
| 773 | 779 | 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"; | ||
| 84 | 84 | import { newForgeScreen } from "./screens/newforge.ts"; |
| 85 | 85 | import { newOrgScreen } from "./screens/neworg.ts"; |
| 86 | 86 | import { migrateScreen } from "./screens/migrate.ts"; |
| 87 | import { bringScreen } from "./screens/bring.ts"; | |
| 87 | 88 | import { newAppScreen } from "./screens/newapp.ts"; |
| 88 | 89 | import { newRepoScreen } from "./screens/newrepo.ts"; |
| 89 | 90 | import { repoScreen } from "./screens/repo.ts"; |
| @@ -246,6 +247,14 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] { | ||
| 246 | 247 | hint: "say what it does in a sentence; the crew starts on it", |
| 247 | 248 | run: () => ctx.push(newAppScreen(session, ctx, blank("New app"))), |
| 248 | 249 | }); |
| 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 | }); | |
| 249 | 258 | // An organization you own, as /organizations/new and MCP `org_create` make it: a person's, never an agent's (#7260). |
| 250 | 259 | if (session.user !== undefined && session.user.kind !== "agent") |
| 251 | 260 | 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 */ ` | ||
| 151 | 151 | paint(job.stage, job.detail); |
| 152 | 152 | if (job.stage === 'done' && job.repo) { |
| 153 | 153 | source.close(); |
| 154 | location.href = '/' + job.repo.ownerName + '/' + job.repo.name; | |
| 154 | location.href = list.getAttribute('data-landing') || '/' + job.repo.ownerName + '/' + job.repo.name; | |
| 155 | 155 | } else if (job.stage === 'failed') { |
| 156 | 156 | // The page for a failed job carries the reason and a way to retry, |
| 157 | 157 | // and does not stream — so this loads it once, and stops. |
src/web/pages/apps.ts +1
| @@ -422,6 +422,7 @@ function newAppForm( | ||
| 422 | 422 | </div> |
| 423 | 423 | </form>`, |
| 424 | 424 | )} |
| 425 | <p class="muted">Already have an app on another forge? <a href="/bring">Bring it here whole</a>.</p> | |
| 425 | 426 | </div>`; |
| 426 | 427 | } |
| 427 | 428 |
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 }[] = [ | ||
| 1011 | 1011 | const ORDER = STAGES.map((s) => s.name); |
| 1012 | 1012 | |
| 1013 | 1013 | /** 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 { | |
| 1015 | 1015 | return html`<ol |
| 1016 | 1016 | class="list stages" |
| 1017 | 1017 | aria-live="polite" |
| 1018 | 1018 | id="stages" |
| 1019 | 1019 | data-stream="${streamUrl}" |
| 1020 | 1020 | data-stage="${job.stage}" |
| 1021 | ${landing === "" ? null : html`data-landing="${landing}"`} | |
| 1021 | 1022 | data-repo="${job.repo === undefined |
| 1022 | 1023 | ? "" |
| 1023 | 1024 | : `/${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 () => { | ||
| 58 | 58 | bot = await mk("builder"); |
| 59 | 59 | carol = await mk("carol"); |
| 60 | 60 | 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); | |
| 61 | 62 | const repo = (rname: string, visibility: string) => { |
| 62 | 63 | const rid = id(); |
| 63 | 64 | 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> = { | ||
| 45 | 45 | "/theme": "a mutation, answers 404 to a GET", |
| 46 | 46 | "/tell": "answers 404 to a GET", |
| 47 | 47 | "/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", | |
| 48 | 49 | "/big": "redirects to /board?wall", |
| 49 | 50 | "/pipeline": "four static words on how a change travels; /flow is the page with numbers", |
| 50 | 51 | "/login": "redirects to /signin", |
test/palette-roster.test.ts +2
| @@ -63,6 +63,7 @@ test("a site-admin person is offered the whole roster", () => { | ||
| 63 | 63 | ["settings", "Repository settings", "owner/name"], |
| 64 | 64 | ["new-repo", "New repository", undefined], |
| 65 | 65 | ["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"], | |
| 66 | 67 | ["new-org", "New organization", "an organization you own, for a client's apps"], |
| 67 | 68 | ["migrate", "Migrate a repository", "bring one in from an origin or a URL, pick its branch, put a crew on it"], |
| 68 | 69 | ["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" | ||
| 218 | 219 | ["settings", "Repository settings", "owner/name"], |
| 219 | 220 | ["new-repo", "New repository", undefined], |
| 220 | 221 | ["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"], | |
| 221 | 223 | ["new-org", "New organization", "an organization you own, for a client's apps"], |
| 222 | 224 | ["migrate", "Migrate a repository", "bring one in from an origin or a URL, pick its branch, put a crew on it"], |
| 223 | 225 | ["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"; | ||
| 52 | 52 | const UNSEEDED: Record<string, string> = { |
| 53 | 53 | "/:owner/-/teams/:team": ORG, |
| 54 | 54 | "/talk/:id": TALK, |
| 55 | "/bring/:id/:job": "needs a bring job of the signed-in person; the seed brings nothing", | |
| 55 | 56 | "/:owner/:name/bets/:id": BET, |
| 56 | 57 | "/:owner/:name/looks/:env/:commit/:kind.png": LOOK, |
| 57 | 58 | "/: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 | }); |
new above
offline
No src/tui/ screen calls this. Both doors are required.