Public: anyone can read this; the crew can change it.
Migrate from the terminal: pick a repository and its branch, put a crew on it, watch the job #3412
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3333-migrate-from-the-terminal-pick-a-reposit → main 4 commits fork 1 thread · 1 unresolved
5 files
changed +869
−0
README.md +13
| @@ -270,6 +270,19 @@ ssh -p 2222 localhost # the terminal UI | ||
| 270 | 270 | git clone ssh://localhost:2222/you/hello.git # git, same port |
| 271 | 271 | |
| 272 | 272 | |
| 273 | **Migrate from the terminal.** Press `M` on the dashboard (or pick "Migrate a | |
| 274 | repository" in the palette, `ctrl+k`). The list is your connected origins' | |
| 275 | repositories, private ones marked; `/` filters it and `u` pastes a URL instead. | |
| 276 | Enter on a repository lists its branches with their last commit, your own | |
| 277 | newest first and marked "(suggested)", so Enter again takes it; a pasted URL | |
| 278 | skips this step. The form asks for name, owner, visibility, agent and autonomy | |
| 279 | (`tab` moves, `space` changes); `ctrl+d` migrates. The job is a checklist of | |
| 280 | stages: `esc` hides it and never cancels, and a toast says when it lands, on the | |
| 281 | new repository. It is the same `migrateRepo` as `/migrate` and | |
| 282 | `POST /api/v1/migrate`: the chosen `branch` becomes the default branch and the | |
| 283 | crew's brief says the app lives on it. The web form takes it too (it lists the | |
| 284 | picked repository's branches newest first), as does the API's `branch` field. | |
| 285 | ||
| 273 | 286 | On a forge made for a client (one that was spawned, signed in by its claim |
| 274 | 287 | link), the home is their apps, one plain sentence each and one more saying |
| 275 | 288 | whether its agents are working, about to start, or waiting: no map, board or |
src/tui/forge.ts +8
| @@ -77,6 +77,7 @@ import { issuesScreen, newIssueScreen } from "./screens/issues.ts"; | ||
| 77 | 77 | import { limbsScreen } from "./screens/limbs.ts"; |
| 78 | 78 | import { newPullScreen } from "./screens/newpull.ts"; |
| 79 | 79 | import { newForgeScreen } from "./screens/newforge.ts"; |
| 80 | import { migrateScreen } from "./screens/migrate.ts"; | |
| 80 | 81 | import { newRepoScreen } from "./screens/newrepo.ts"; |
| 81 | 82 | import { repoScreen } from "./screens/repo.ts"; |
| 82 | 83 | import { signInScreen } from "./screens/signin.ts"; |
| @@ -206,6 +207,13 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] { | ||
| 206 | 207 | ctx.push(signInScreen(session, ctx, () => ctx.invalidate())), |
| 207 | 208 | }, |
| 208 | 209 | ); |
| 210 | if (session.signedIn) | |
| 211 | commands.push({ | |
| 212 | id: "migrate", | |
| 213 | title: "Migrate a repository", | |
| 214 | hint: "bring one in from an origin or a URL, pick its branch, put a crew on it", | |
| 215 | run: () => ctx.push(migrateScreen(session, ctx)), | |
| 216 | }); | |
| 209 | 217 | // A client's forge, as /forges/new makes it: any signed-in person's; the door decides the rest. |
| 210 | 218 | if (session.signedIn) |
| 211 | 219 | commands.push({ |
src/tui/screens/dashboard.ts +6
| @@ -92,6 +92,7 @@ import { running } from "../../kernel/version.ts"; | ||
| 92 | 92 | import { deciderLine, deciderScreen } from "./decider.ts"; |
| 93 | 93 | import { issueScreen } from "./issue.ts"; |
| 94 | 94 | import { listPinned } from "../../actions/board-pins.ts"; |
| 95 | import { migrateScreen } from "./migrate.ts"; | |
| 95 | 96 | import { newRepoScreen } from "./newrepo.ts"; |
| 96 | 97 | import { pullScreen } from "./pull.ts"; |
| 97 | 98 | import { repoScreen } from "./repo.ts"; |
| @@ -759,6 +760,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 759 | 760 | }), |
| 760 | 761 | binding(["C"], "decider", { disabled: !f.user }), |
| 761 | 762 | binding(["n"], "new repo", { disabled: !f.signedIn }), |
| 763 | binding(["M"], "migrate", { disabled: !f.signedIn }), | |
| 762 | 764 | binding(["A"], "account", { disabled: !f.signedIn }), |
| 763 | 765 | binding(["s"], "sign in", { disabled: f.signedIn }), |
| 764 | 766 | ...(focus === "repos" && f.user !== undefined && shown().some(mayArchive) |
| @@ -955,6 +957,10 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 955 | 957 | ctx.push(newRepoScreen(f, ctx)); |
| 956 | 958 | return true; |
| 957 | 959 | } |
| 960 | if (e.key === "M" && f.signedIn) { | |
| 961 | ctx.push(migrateScreen(f, ctx)); | |
| 962 | return true; | |
| 963 | } | |
| 958 | 964 | if (e.key === "A" && f.user) { |
| 959 | 965 | openOnWeb(f, ctx, "/settings", "Account settings"); |
| 960 | 966 | return true; |
src/tui/screens/migrate.ts added +536
| @@ -0,0 +1,536 @@ | ||
| 1 | /** | |
| 2 | * Migrate from the terminal: the web's `/migrate`, one step per screen. | |
| 3 | * | |
| 4 | * Pick a repository from your connected origins (or paste a URL), pick the | |
| 5 | * branch the app lives on, say where it lands and who works on it, then watch | |
| 6 | * the job. Nothing here is a second path: the last step is `migrateRepo` | |
| 7 | * with `branch` and `crew`, and this file only puts its questions and its | |
| 8 | * answer on a terminal. | |
| 9 | */ | |
| 10 | ||
| 11 | import { listAllAgents } from "../../actions/crew.ts"; | |
| 12 | import { | |
| 13 | migrateMessage, | |
| 14 | migrateRepo, | |
| 15 | type MigrateSource, | |
| 16 | } from "../../actions/migrate.ts"; | |
| 17 | import { listRemoteRepos, listUserOrigins } from "../../actions/origins.ts"; | |
| 18 | import { repositoryLimitMessage } from "../../actions/repos.ts"; | |
| 19 | import { accessAllows, listOrgsForUser, orgAccess } from "../../core/orgs.ts"; | |
| 20 | import type { OriginRepoRow } from "../../core/origins.ts"; | |
| 21 | import type { User } from "../../core/users.ts"; | |
| 22 | import { subscribe, type MirrorJob, type MirrorStage } from "../../origins/jobs.ts"; | |
| 23 | import { providerForOrigin } from "../../origins/credential.ts"; | |
| 24 | import { cachedRemoteTips, pickWorkBranch } from "../../origins/work-branch.ts"; | |
| 25 | import { getOriginFor } from "../../core/origins.ts"; | |
| 26 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 27 | import { binding } from "../app/keys.ts"; | |
| 28 | import { G } from "../term/screen.ts"; | |
| 29 | import { style } from "../term/style.ts"; | |
| 30 | import { truncate } from "../term/width.ts"; | |
| 31 | import { fill, len, split } from "../ui/layout.ts"; | |
| 32 | import { card, field, spinner } from "../ui/widgets.ts"; | |
| 33 | import type { Session } from "../session.ts"; | |
| 34 | import { | |
| 35 | ago, | |
| 36 | drawList, | |
| 37 | Filter, | |
| 38 | hitList, | |
| 39 | ListState, | |
| 40 | pickerScreen, | |
| 41 | promptScreen, | |
| 42 | runAsync, | |
| 43 | } from "./common.ts"; | |
| 44 | import { repoScreen } from "./repo.ts"; | |
| 45 | import { local } from "./where.ts"; | |
| 46 | ||
| 47 | interface Row { | |
| 48 | readonly originId: string; | |
| 49 | readonly origin: string; | |
| 50 | readonly repo: OriginRepoRow; | |
| 51 | } | |
| 52 | ||
| 53 | /** Where the repository comes from, and — for an origin — the branch chosen. */ | |
| 54 | interface Picked { | |
| 55 | readonly source: MigrateSource; | |
| 56 | readonly label: string; | |
| 57 | readonly name: string; | |
| 58 | readonly branch?: string; | |
| 59 | } | |
| 60 | ||
| 61 | const STAGES: readonly { stage: MirrorStage; label: string }[] = [ | |
| 62 | { stage: "creating", label: "Creating the repository" }, | |
| 63 | { stage: "fetching", label: "Cloning every branch and tag" }, | |
| 64 | { stage: "crew", label: "Putting the crew on it" }, | |
| 65 | ]; | |
| 66 | ||
| 67 | /** Step one: the repositories of every connected origin, or a pasted URL. */ | |
| 68 | export function migrateScreen(f: Session, ctx: Ctx): Screen { | |
| 69 | const user = f.user; | |
| 70 | const state = new ListState(); | |
| 71 | const filter = new Filter(); | |
| 72 | let rows: Row[] = []; | |
| 73 | let loading = true; | |
| 74 | // Origins that would not answer: said aloud, never read as "no repositories". | |
| 75 | let unreachable: string[] = []; | |
| 76 | let self: Screen; | |
| 77 | ||
| 78 | const shown = (): Row[] => | |
| 79 | rows.filter((r) => filter.matches(r.repo.fullName)); | |
| 80 | ||
| 81 | const toForm = (picked: Picked): void => { | |
| 82 | if (user !== undefined) ctx.push(migrateFormScreen(f, ctx, user, picked)); | |
| 83 | }; | |
| 84 | ||
| 85 | const choose = (row: Row): void => { | |
| 86 | if (user === undefined) return; | |
| 87 | runAsync(ctx, async () => { | |
| 88 | const origin = getOriginFor(f.db, user.id, row.originId); | |
| 89 | const provider = | |
| 90 | origin === undefined ? undefined : providerForOrigin(f.app, origin); | |
| 91 | const tips = | |
| 92 | origin === undefined || provider === undefined || !provider.ok | |
| 93 | ? undefined | |
| 94 | : await cachedRemoteTips(origin.id, provider.value, row.repo.fullName, { | |
| 95 | logins: [origin.login, user.username], | |
| 96 | emails: [user.email], | |
| 97 | }); | |
| 98 | if (tips === undefined || tips.length === 0) { | |
| 99 | ctx.toast( | |
| 100 | tips === undefined | |
| 101 | ? "Could not reach the origin. Try again." | |
| 102 | : `${row.repo.fullName} has no branches`, | |
| 103 | "danger", | |
| 104 | ); | |
| 105 | return; | |
| 106 | } | |
| 107 | const suggested = pickWorkBranch(tips, row.repo.defaultBranch); | |
| 108 | // Newest first, the suggestion on top, so Enter takes it. | |
| 109 | const ordered = [...tips] | |
| 110 | .sort((a, b) => (Date.parse(b.date) || 0) - (Date.parse(a.date) || 0)) | |
| 111 | .sort( | |
| 112 | (a, b) => Number(b.name === suggested) - Number(a.name === suggested), | |
| 113 | ); | |
| 114 | ctx.push( | |
| 115 | pickerScreen(ctx, self, { | |
| 116 | title: `Branch of ${row.repo.fullName}`, | |
| 117 | items: ordered.map((tip) => ({ | |
| 118 | id: tip.name, | |
| 119 | label: tip.name === suggested ? `${tip.name} (suggested)` : tip.name, | |
| 120 | hint: [ | |
| 121 | tip.subject, | |
| 122 | tip.author, | |
| 123 | tip.date === "" ? "" : ago(tip.date), | |
| 124 | ] | |
| 125 | .filter((p) => p !== "") | |
| 126 | .join(" · "), | |
| 127 | })), | |
| 128 | onPick: ([branch]) => { | |
| 129 | if (branch === undefined) return; | |
| 130 | toForm({ | |
| 131 | source: { originId: row.originId, fullName: row.repo.fullName }, | |
| 132 | label: row.repo.fullName, | |
| 133 | name: row.repo.fullName.split("/").pop() ?? "", | |
| 134 | branch, | |
| 135 | }); | |
| 136 | }, | |
| 137 | }), | |
| 138 | ); | |
| 139 | }); | |
| 140 | }; | |
| 141 | ||
| 142 | const pasteUrl = (): void => { | |
| 143 | ctx.push( | |
| 144 | promptScreen(ctx, self, { | |
| 145 | title: "Migrate from a URL", | |
| 146 | label: "Repository URL", | |
| 147 | placeholder: "https://github.com/owner/name", | |
| 148 | hint: "Public, over https. Cloned anonymously.", | |
| 149 | onSubmit: (url) => { | |
| 150 | if (url === "") return "Paste an address first."; | |
| 151 | const name = | |
| 152 | (url.replace(/[?#].*$/, "").replace(/\/+$/, "").split("/").pop() ?? "") | |
| 153 | .replace(/\.git$/i, ""); | |
| 154 | return Promise.resolve().then(() => | |
| 155 | toForm({ source: { url }, label: url, name }), | |
| 156 | ); | |
| 157 | }, | |
| 158 | }), | |
| 159 | ); | |
| 160 | }; | |
| 161 | ||
| 162 | self = { | |
| 163 | title: "Migrate a repository", | |
| 164 | get keys() { | |
| 165 | return [ | |
| 166 | binding(["j", "k"], "move"), | |
| 167 | binding(["enter"], "pick branch"), | |
| 168 | binding(["/"], "filter"), | |
| 169 | binding(["u"], "paste a URL"), | |
| 170 | binding(["esc"], "back"), | |
| 171 | ]; | |
| 172 | }, | |
| 173 | async load() { | |
| 174 | if (user === undefined) { | |
| 175 | loading = false; | |
| 176 | return; | |
| 177 | } | |
| 178 | const out: Row[] = []; | |
| 179 | const failed: string[] = []; | |
| 180 | for (const origin of listUserOrigins(f.app, user)) { | |
| 181 | const listed = await listRemoteRepos(f.app, user, origin.id); | |
| 182 | if (!listed.ok) { | |
| 183 | failed.push(origin.login); | |
| 184 | continue; | |
| 185 | } | |
| 186 | for (const repo of listed.value) | |
| 187 | out.push({ originId: origin.id, origin: origin.login, repo }); | |
| 188 | } | |
| 189 | rows = out; | |
| 190 | unreachable = failed; | |
| 191 | loading = false; | |
| 192 | ctx.invalidate(); | |
| 193 | }, | |
| 194 | render(area, s) { | |
| 195 | const c = ctx.theme.c; | |
| 196 | const [top, list] = split(area, "v", [len(2), fill(1)]); | |
| 197 | ctx.setCursor(filter.render(s, { ...top!, h: 1 }, c, "filter repositories")); | |
| 198 | const items = shown(); | |
| 199 | const down = `Could not reach ${unreachable.join(", ")}.`; | |
| 200 | if (unreachable.length > 0 && items.length > 0) | |
| 201 | s.text( | |
| 202 | top!.x, | |
| 203 | top!.y + 1, | |
| 204 | truncate(`${down} The others are listed.`, top!.w), | |
| 205 | style(c.danger, c.bg), | |
| 206 | top!.x + top!.w, | |
| 207 | ); | |
| 208 | if (items.length === 0) { | |
| 209 | s.text( | |
| 210 | list!.x, | |
| 211 | list!.y, | |
| 212 | truncate( | |
| 213 | loading | |
| 214 | ? "loading your origins…" | |
| 215 | : rows.length === 0 && unreachable.length > 0 | |
| 216 | ? `${down} Try again, or press u to paste a URL.` | |
| 217 | : rows.length === 0 | |
| 218 | ? "No connected origin has repositories. Press u to paste a URL, or connect one on the web under Settings → Origins." | |
| 219 | : "Nothing matches.", | |
| 220 | list!.w, | |
| 221 | ), | |
| 222 | style(c.mutedFg, c.bg), | |
| 223 | list!.x + list!.w, | |
| 224 | ); | |
| 225 | return; | |
| 226 | } | |
| 227 | drawList( | |
| 228 | s, | |
| 229 | list!, | |
| 230 | c, | |
| 231 | state, | |
| 232 | items.map((r) => ({ | |
| 233 | title: r.repo.fullName, | |
| 234 | secondary: r.repo.private ? "private" : "", | |
| 235 | meta: [ | |
| 236 | r.origin, | |
| 237 | r.repo.pushedAt === null ? "" : `pushed ${ago(r.repo.pushedAt)}`, | |
| 238 | ] | |
| 239 | .filter((p) => p !== "") | |
| 240 | .join(" · "), | |
| 241 | })), | |
| 242 | ); | |
| 243 | hitList(ctx, list!, state, items.length, { | |
| 244 | open: (i) => { | |
| 245 | const row = items[i]; | |
| 246 | if (row !== undefined) choose(row); | |
| 247 | }, | |
| 248 | }); | |
| 249 | }, | |
| 250 | key(e) { | |
| 251 | if (filter.key(e)) { | |
| 252 | state.clamp(shown().length); | |
| 253 | return true; | |
| 254 | } | |
| 255 | if (e.key === "esc") { | |
| 256 | ctx.pop(); | |
| 257 | return true; | |
| 258 | } | |
| 259 | if (e.key === "u") { | |
| 260 | pasteUrl(); | |
| 261 | return true; | |
| 262 | } | |
| 263 | if (state.key(e, shown().length)) return true; | |
| 264 | if (e.key === "enter") { | |
| 265 | const row = shown()[state.index]; | |
| 266 | if (row !== undefined) choose(row); | |
| 267 | return true; | |
| 268 | } | |
| 269 | return true; | |
| 270 | }, | |
| 271 | }; | |
| 272 | return self; | |
| 273 | } | |
| 274 | ||
| 275 | type Focus = 0 | 1 | 2 | 3 | 4; | |
| 276 | ||
| 277 | /** Step three: where it lands, and who works on it. */ | |
| 278 | function migrateFormScreen( | |
| 279 | f: Session, | |
| 280 | ctx: Ctx, | |
| 281 | user: User, | |
| 282 | picked: Picked, | |
| 283 | ): Screen { | |
| 284 | const writeOrgs = listOrgsForUser(f.db, user.id).filter((org) => | |
| 285 | accessAllows(orgAccess(f.db, org.id, user), "write"), | |
| 286 | ); | |
| 287 | const owners = [user.username, ...writeOrgs.map((o) => o.username)]; | |
| 288 | const agents = listAllAgents(f.app, user); | |
| 289 | // "" is nobody: just the repository. | |
| 290 | const crew = ["", ...(agents.ok ? agents.value.map((a) => a.user.username) : [])]; | |
| 291 | let name = picked.name; | |
| 292 | let ownerIndex = 0; | |
| 293 | let visibility: "public" | "private" = "private"; | |
| 294 | let agentIndex = 0; | |
| 295 | let autonomous = false; | |
| 296 | let focus: Focus = 0; | |
| 297 | let error: string | undefined; | |
| 298 | let busy = false; | |
| 299 | ||
| 300 | const submit = (): void => { | |
| 301 | if (busy) return; | |
| 302 | busy = true; | |
| 303 | runAsync(ctx, async () => { | |
| 304 | try { | |
| 305 | const agent = crew[agentIndex] ?? ""; | |
| 306 | const started = await migrateRepo(f.app, user, { | |
| 307 | source: picked.source, | |
| 308 | owner: owners[ownerIndex] ?? user.username, | |
| 309 | name, | |
| 310 | visibility, | |
| 311 | ...(picked.branch === undefined ? {} : { branch: picked.branch }), | |
| 312 | ...(agent === "" ? {} : { crew: { agent, autonomous } }), | |
| 313 | }); | |
| 314 | if (!started.ok) { | |
| 315 | error = migrateMessage( | |
| 316 | started.error, | |
| 317 | repositoryLimitMessage(f.app, user), | |
| 318 | ); | |
| 319 | ctx.toast(error, "danger"); | |
| 320 | ctx.invalidate(); | |
| 321 | return; | |
| 322 | } | |
| 323 | ctx.replace(jobScreen(f, ctx, started.value.job, picked.label)); | |
| 324 | } finally { | |
| 325 | busy = false; | |
| 326 | } | |
| 327 | }); | |
| 328 | }; | |
| 329 | ||
| 330 | const cycle = (): void => { | |
| 331 | if (focus === 1) ownerIndex = (ownerIndex + 1) % owners.length; | |
| 332 | if (focus === 2) visibility = visibility === "private" ? "public" : "private"; | |
| 333 | if (focus === 3) agentIndex = (agentIndex + 1) % crew.length; | |
| 334 | if (focus === 4) autonomous = !autonomous; | |
| 335 | }; | |
| 336 | ||
| 337 | return { | |
| 338 | title: "Migrate", | |
| 339 | keys: [ | |
| 340 | binding(["tab"], "next field"), | |
| 341 | binding(["space"], "change"), | |
| 342 | binding(["ctrl+d"], "migrate"), | |
| 343 | binding(["esc"], "back"), | |
| 344 | ], | |
| 345 | render(area, s) { | |
| 346 | const c = ctx.theme.c; | |
| 347 | const [top, rest] = split(area, "v", [len(5), fill(1)]); | |
| 348 | const right = area.x + area.w; | |
| 349 | s.text( | |
| 350 | area.x, | |
| 351 | area.y, | |
| 352 | truncate( | |
| 353 | `from ${picked.label}${picked.branch === undefined ? "" : ` · branch ${picked.branch}`}`, | |
| 354 | area.w, | |
| 355 | ), | |
| 356 | style(c.mutedFg, c.bg), | |
| 357 | right, | |
| 358 | ); | |
| 359 | const cursor = field(s, { x: top!.x, y: top!.y + 1, w: top!.w, h: 3 }, c, { | |
| 360 | label: `Name (under ${owners[ownerIndex] ?? "?"})`, | |
| 361 | value: name, | |
| 362 | focused: focus === 0, | |
| 363 | ...(focus === 0 ? { cursor: [...name].length } : {}), | |
| 364 | ...(error === undefined | |
| 365 | ? { hint: "Blank keeps the source's name." } | |
| 366 | : { error }), | |
| 367 | }); | |
| 368 | ctx.setCursor(focus === 0 ? cursor : null); | |
| 369 | const lines = [ | |
| 370 | `owner ${owners[ownerIndex] ?? "?"}`, | |
| 371 | `visibility ${visibility}`, | |
| 372 | `agent ${crew[agentIndex] === "" ? "nobody — just the repository" : crew[agentIndex]}`, | |
| 373 | `autonomy ${autonomous ? "autonomous — green ships on its own" : "every green change waits for your look"}`, | |
| 374 | ]; | |
| 375 | lines.forEach((line, i) => | |
| 376 | s.text( | |
| 377 | rest!.x, | |
| 378 | rest!.y + i, | |
| 379 | truncate(line, rest!.w), | |
| 380 | style(focus === i + 1 ? c.accent : c.mutedFg, c.bg), | |
| 381 | rest!.x + rest!.w, | |
| 382 | ), | |
| 383 | ); | |
| 384 | }, | |
| 385 | paste(text) { | |
| 386 | if (focus === 0) name += text.replace(/\s+/g, ""); | |
| 387 | return true; | |
| 388 | }, | |
| 389 | key(e) { | |
| 390 | if (e.key === "esc") { | |
| 391 | ctx.pop(); | |
| 392 | return true; | |
| 393 | } | |
| 394 | if (e.ctrl && e.key === "d") { | |
| 395 | submit(); | |
| 396 | return true; | |
| 397 | } | |
| 398 | if (e.key === "tab") { | |
| 399 | focus = ((focus + 1) % 5) as Focus; | |
| 400 | return true; | |
| 401 | } | |
| 402 | if (focus > 0 && (e.key === " " || e.key === "enter")) { | |
| 403 | cycle(); | |
| 404 | return true; | |
| 405 | } | |
| 406 | if (e.key === "enter") { | |
| 407 | submit(); | |
| 408 | return true; | |
| 409 | } | |
| 410 | if (focus === 0 && e.key === "backspace") { | |
| 411 | name = [...name].slice(0, -1).join(""); | |
| 412 | error = undefined; | |
| 413 | return true; | |
| 414 | } | |
| 415 | if (focus === 0 && !e.ctrl && !e.alt && [...e.key].length === 1) { | |
| 416 | name += e.key; | |
| 417 | error = undefined; | |
| 418 | } | |
| 419 | return true; | |
| 420 | }, | |
| 421 | }; | |
| 422 | } | |
| 423 | ||
| 424 | /** | |
| 425 | * Step four: watching the job, as `mirror.ts` watches a mirror. Esc hides the | |
| 426 | * card and never cancels; the watcher toasts when it lands either way, and | |
| 427 | * the card itself replaces itself with the repository when it finishes. | |
| 428 | */ | |
| 429 | function jobScreen( | |
| 430 | f: Session, | |
| 431 | ctx: Ctx, | |
| 432 | started: MirrorJob, | |
| 433 | label: string, | |
| 434 | ): Screen { | |
| 435 | let job = started; | |
| 436 | // Not tied to this card: hiding it must not silence the outcome. It ends | |
| 437 | // itself at done or failed, which every job reaches. | |
| 438 | const watching = subscribe(job.id, (current) => { | |
| 439 | if (current.stage === "done") { | |
| 440 | watching(); | |
| 441 | ctx.toast(current.detail === "" ? "migrated" : current.detail, "success"); | |
| 442 | } else if (current.stage === "failed") { | |
| 443 | watching(); | |
| 444 | ctx.toast(current.error ?? "the migration failed", "danger"); | |
| 445 | } | |
| 446 | }); | |
| 447 | ||
| 448 | return { | |
| 449 | title: `Migrating ${label}`, | |
| 450 | keys: [binding(["esc"], "hide — it keeps running")], | |
| 451 | load(signal) { | |
| 452 | const off = subscribe(job.id, (current) => { | |
| 453 | job = current; | |
| 454 | ctx.invalidate(); | |
| 455 | if (current.stage === "done" && current.repo !== undefined) | |
| 456 | ctx.replace(repoScreen(f, ctx, local(f, current.repo))); | |
| 457 | }); | |
| 458 | const timer = setInterval(() => ctx.invalidate(), 120); | |
| 459 | signal.addEventListener("abort", () => { | |
| 460 | off(); | |
| 461 | clearInterval(timer); | |
| 462 | }); | |
| 463 | return Promise.resolve(); | |
| 464 | }, | |
| 465 | render(area, s) { | |
| 466 | const c = ctx.theme.c; | |
| 467 | const w = Math.min(64, Math.max(24, area.w - 6)); | |
| 468 | const h = Math.min(area.h, STAGES.length + 4); | |
| 469 | const inner = card( | |
| 470 | s, | |
| 471 | { | |
| 472 | x: area.x + ((area.w - w) >> 1), | |
| 473 | y: area.y + ((area.h - h) >> 1), | |
| 474 | w, | |
| 475 | h, | |
| 476 | }, | |
| 477 | c, | |
| 478 | { | |
| 479 | title: `Migrating ${label}`, | |
| 480 | footer: "esc hides this", | |
| 481 | focused: true, | |
| 482 | ...(job.stage === "failed" ? { tone: "danger" as const } : {}), | |
| 483 | }, | |
| 484 | ); | |
| 485 | const right = inner.x + inner.w; | |
| 486 | const at = STAGES.findIndex((x) => x.stage === job.stage); | |
| 487 | const finished = job.stage === "done"; | |
| 488 | STAGES.forEach(({ label: text }, i) => { | |
| 489 | const y = inner.y + i; | |
| 490 | const past = finished || (at >= 0 && i < at); | |
| 491 | const now = at === i; | |
| 492 | let x = inner.x; | |
| 493 | if (now) x = spinner(s, x, y, c, ctx.tick); | |
| 494 | else | |
| 495 | x = s.text( | |
| 496 | x, | |
| 497 | y, | |
| 498 | past ? G.check : G.ring, | |
| 499 | style(past ? c.success : c.faint, c.card), | |
| 500 | right, | |
| 501 | ); | |
| 502 | x = s.text( | |
| 503 | x + 1, | |
| 504 | y, | |
| 505 | truncate(text, Math.max(0, right - x - 1)), | |
| 506 | style(now ? c.fg : past ? c.mutedFg : c.faint, c.card), | |
| 507 | right, | |
| 508 | ); | |
| 509 | if (now && job.detail !== "") | |
| 510 | s.text( | |
| 511 | x + 2, | |
| 512 | y, | |
| 513 | truncate(job.detail, Math.max(0, right - x - 2)), | |
| 514 | style(c.mutedFg, c.card), | |
| 515 | right, | |
| 516 | ); | |
| 517 | }); | |
| 518 | const y = inner.y + STAGES.length; | |
| 519 | if (job.stage === "failed") | |
| 520 | s.text( | |
| 521 | inner.x, | |
| 522 | y, | |
| 523 | truncate(`${G.cross} ${job.error ?? "the migration failed"}`, inner.w), | |
| 524 | style(c.danger, c.card), | |
| 525 | right, | |
| 526 | ); | |
| 527 | }, | |
| 528 | key(e) { | |
| 529 | if (e.key === "esc" || e.key === "q") { | |
| 530 | ctx.pop(); | |
| 531 | ctx.toast("migrating in the background", "default"); | |
| 532 | } | |
| 533 | return true; | |
| 534 | }, | |
| 535 | }; | |
| 536 | } |
test/tui-migrate.test.ts added +306
| @@ -0,0 +1,306 @@ | ||
| 1 | /** | |
| 2 | * Migrate from the terminal: pick a repository from a connected origin, pick | |
| 3 | * the branch the app lives on, put a crew on it, and end on the repository | |
| 4 | * with the crew's brief naming that branch. The origin is the fake forge | |
| 5 | * from `migrate.test.ts`'s world; the clone is real smart HTTP. | |
| 6 | */ | |
| 7 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 8 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { createApp, type App } from "../src/app.ts"; | |
| 12 | import { createAgent } from "../src/actions/crew.ts"; | |
| 13 | import { connectOrigin } from "../src/actions/origins.ts"; | |
| 14 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 15 | import { listIssues } from "../src/core/issues.ts"; | |
| 16 | import { getRepo } from "../src/core/repos.ts"; | |
| 17 | import { createToken } from "../src/core/tokens.ts"; | |
| 18 | import { createUser, type User } from "../src/core/users.ts"; | |
| 19 | import { repoDir } from "../src/git/repos.ts"; | |
| 20 | import { runGitCapture } from "../src/git/env.ts"; | |
| 21 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 22 | import { forgetProviders } from "../src/origins/providers/index.ts"; | |
| 23 | import { createForgeApp, forgeCommands, Session } from "../src/tui/forge.ts"; | |
| 24 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 25 | import { newFakeState, serveFakeForge, type FakeForge } from "./fake-origin.ts"; | |
| 26 | import { quietLogs } from "./helpers/globals.ts"; | |
| 27 | import { promote } from "./helpers/members.ts"; | |
| 28 | import { settleApp } from "./helpers/tui.ts"; | |
| 29 | ||
| 30 | quietLogs(); | |
| 31 | ||
| 32 | let dir: string; | |
| 33 | let app: App; | |
| 34 | let servers: Servers; | |
| 35 | let fake: FakeForge; | |
| 36 | let alice: User; | |
| 37 | let tipOfWork = ""; | |
| 38 | let secretToken = ""; | |
| 39 | let base = ""; | |
| 40 | ||
| 41 | const identity = (user: User) => ({ | |
| 42 | sshUsername: user.username, | |
| 43 | remoteAddress: "127.0.0.1", | |
| 44 | fingerprint: "SHA256:test", | |
| 45 | user, | |
| 46 | }); | |
| 47 | ||
| 48 | async function until(check: () => boolean, ms = 20_000): Promise<void> { | |
| 49 | const end = Date.now() + ms; | |
| 50 | while (!check()) { | |
| 51 | if (Date.now() > end) throw new Error("timed out waiting"); | |
| 52 | await new Promise((r) => setTimeout(r, 25)); | |
| 53 | } | |
| 54 | } | |
| 55 | ||
| 56 | beforeAll(async () => { | |
| 57 | dir = await mkdtemp(join(tmpdir(), "forge-tui-migrate-")); | |
| 58 | app = createApp({ dataDir: dir, port: 0, migrateFromPrivateHosts: true }); | |
| 59 | servers = await serve(app); | |
| 60 | base = `http://127.0.0.1:${servers.port}`; | |
| 61 | const person = async (username: string, adminIfFirst = false) => { | |
| 62 | const made = await createUser(app.db, { | |
| 63 | origin: "seed", | |
| 64 | trusted: true, | |
| 65 | username, | |
| 66 | email: `${username}@forge.test`, | |
| 67 | adminIfFirst, | |
| 68 | }); | |
| 69 | if (!made.ok) throw new Error(String(made.error)); | |
| 70 | promote(app.db, made.value); | |
| 71 | return made.value; | |
| 72 | }; | |
| 73 | alice = await person("alice", true); | |
| 74 | const octo = await person("octo"); | |
| 75 | const secret = createToken(app.db, octo.id, "upstream", "rw").token; | |
| 76 | secretToken = secret; | |
| 77 | ||
| 78 | // The "upstream": two branches, the app on `work`. | |
| 79 | const made = await createRepo(app, octo, { name: "widgets", visibility: "private" }); | |
| 80 | if (!made.ok) throw new Error(made.error); | |
| 81 | for (const [branch, path] of [ | |
| 82 | ["main", "README.md"], | |
| 83 | ["work", "app.ts"], | |
| 84 | ] as const) { | |
| 85 | const done = await commitToBranch(app, octo, made.value, { | |
| 86 | branch, | |
| 87 | message: `on ${branch}`, | |
| 88 | files: [{ path, content: `${branch}\n` }], | |
| 89 | }); | |
| 90 | if (!done.ok) throw new Error(String(done.error)); | |
| 91 | } | |
| 92 | const dirOf = repoDir(app.reposRoot, "octo", "widgets"); | |
| 93 | const sha = async (ref: string) => | |
| 94 | (await runGitCapture(["rev-parse", ref], { cwd: dirOf })).stdout.trim(); | |
| 95 | tipOfWork = await sha("work"); | |
| 96 | ||
| 97 | const state = newFakeState("forgejo", "octo", secret); | |
| 98 | state.repos.push({ | |
| 99 | id: 5, | |
| 100 | fullName: "octo/widgets", | |
| 101 | description: "gears", | |
| 102 | cloneUrl: `${base}/octo/widgets.git`, | |
| 103 | htmlUrl: `${base}/octo/widgets`, | |
| 104 | defaultBranch: "main", | |
| 105 | private: true, | |
| 106 | }); | |
| 107 | state.branches.set("octo/widgets", [ | |
| 108 | { name: "main", sha: await sha("main") }, | |
| 109 | { name: "work", sha: tipOfWork }, | |
| 110 | ]); | |
| 111 | // `main` was touched last, by somebody else; `work` is octo's own. | |
| 112 | state.commits.set("octo/widgets", [ | |
| 113 | { sha: await sha("main"), message: "on main", authorName: "Zed", login: "zed", date: "2026-10-19T10:00:00Z" }, | |
| 114 | { sha: tipOfWork, message: "on work", authorName: "Octo", login: "octo", date: "2026-10-01T10:00:00Z" }, | |
| 115 | ]); | |
| 116 | // A public repository for the paste-a-URL path. | |
| 117 | const pub = await createRepo(app, octo, { name: "pub", visibility: "public" }); | |
| 118 | if (!pub.ok) throw new Error(pub.error); | |
| 119 | const pubDone = await commitToBranch(app, octo, pub.value, { | |
| 120 | branch: "main", | |
| 121 | message: "hello", | |
| 122 | files: [{ path: "README.md", content: "hi\n" }], | |
| 123 | }); | |
| 124 | if (!pubDone.ok) throw new Error(String(pubDone.error)); | |
| 125 | // Repositories whose branches are none, or cannot be read. | |
| 126 | for (const [id, fullName] of [[7, "octo/empty"], [8, "octo/flaky"]] as const) | |
| 127 | state.repos.push({ id, fullName, cloneUrl: `${base}/${fullName}.git`, defaultBranch: "main", private: false }); | |
| 128 | state.branches.set("octo/empty", []); | |
| 129 | state.branches.set("octo/flaky", [{ name: "main", sha: "a".repeat(40) }]); | |
| 130 | fake = serveFakeForge(state); | |
| 131 | forgetProviders(dir); | |
| 132 | const connected = await connectOrigin(app, alice, { host: fake.url, token: secret }); | |
| 133 | if (!connected.ok) throw new Error(connected.error); | |
| 134 | ||
| 135 | const agent = await createAgent(app, alice, { username: "porter", model: "", brief: "Moves things in." }); | |
| 136 | if (!agent.ok) throw new Error(String(agent.error)); | |
| 137 | }, 30_000); | |
| 138 | ||
| 139 | afterAll(async () => { | |
| 140 | fake.stop(); | |
| 141 | servers.stop(); | |
| 142 | app.close(); | |
| 143 | forgetProviders(dir); | |
| 144 | await rm(dir, { recursive: true, force: true }); | |
| 145 | }); | |
| 146 | ||
| 147 | describe("migrate from the terminal", () => { | |
| 148 | test("pick a repository, the person's own newest branch is first, a crew goes on it, and it ends on the repository", async () => { | |
| 149 | const terminal = headlessTerminal(120, 40); | |
| 150 | const { app: tui } = createForgeApp(app, identity(alice), { terminal, exitHooks: false }); | |
| 151 | tui.start(); | |
| 152 | try { | |
| 153 | await settleApp(tui); | |
| 154 | terminal.feed("M"); | |
| 155 | await settleApp(tui); | |
| 156 | expect(tui.screen.toText()).toContain("octo/widgets"); | |
| 157 | expect(tui.screen.toText()).toContain("private"); | |
| 158 | ||
| 159 | terminal.feed("/widgets\r"); // filter, then | |
| 160 | terminal.feed("\r"); // the repository: its branches | |
| 161 | await settleApp(tui); | |
| 162 | const branches = tui.screen.toText(); | |
| 163 | expect(branches).toContain("Branch of octo/widgets"); | |
| 164 | // `main` is newer, but `work` is octo's own, so it is on top and suggested. | |
| 165 | expect(branches.indexOf("work")).toBeLessThan(branches.indexOf("main")); | |
| 166 | expect(branches).toContain("suggested"); | |
| 167 | ||
| 168 | terminal.feed("\r"); // take the suggestion | |
| 169 | await settleApp(tui); | |
| 170 | expect(tui.screen.toText()).toContain("branch work"); | |
| 171 | ||
| 172 | terminal.feed("\t\t\t "); // owner, visibility, agent → porter | |
| 173 | await settleApp(tui); | |
| 174 | expect(tui.screen.toText()).toContain("porter"); | |
| 175 | terminal.feed("\x04"); // ctrl+d: migrate | |
| 176 | await until(() => listIssues(app.db, getRepo(app.db, "alice", "widgets")?.id ?? "", { kind: "issue", state: "open" }).length === 1); | |
| 177 | await settleApp(tui); | |
| 178 | ||
| 179 | const repo = getRepo(app.db, "alice", "widgets")!; | |
| 180 | expect(repo.defaultBranch).toBe("work"); | |
| 181 | const head = await runGitCapture(["rev-parse", "work"], { cwd: repoDir(app.reposRoot, "alice", "widgets") }); | |
| 182 | expect(head.stdout.trim()).toBe(tipOfWork); | |
| 183 | const brief = listIssues(app.db, repo.id, { kind: "issue", state: "open" })[0]!; | |
| 184 | expect(brief.assigneeName).toBe("porter"); | |
| 185 | expect(brief.body).toContain("The app is on the branch `work`"); | |
| 186 | // Done lands on the repository. | |
| 187 | await until(() => tui.screen.toText().includes("alice/widgets")); | |
| 188 | } finally { | |
| 189 | tui.stop?.(); | |
| 190 | } | |
| 191 | }, 60_000); | |
| 192 | ||
| 193 | /** A terminal for `user` (or nobody), with `body` run and the terminal always stopped. */ | |
| 194 | async function withTui( | |
| 195 | user: User | undefined, | |
| 196 | body: (h: { tui: ReturnType<typeof createForgeApp>["app"]; terminal: ReturnType<typeof headlessTerminal> }) => Promise<void>, | |
| 197 | ): Promise<void> { | |
| 198 | const terminal = headlessTerminal(120, 40); | |
| 199 | const id = user === undefined ? { sshUsername: "git", remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user } : identity(user); | |
| 200 | const { app: tui } = createForgeApp(app, id, { terminal, exitHooks: false }); | |
| 201 | tui.start(); | |
| 202 | try { | |
| 203 | await settleApp(tui); | |
| 204 | await body({ tui, terminal }); | |
| 205 | } finally { | |
| 206 | tui.stop?.(); | |
| 207 | } | |
| 208 | } | |
| 209 | ||
| 210 | test("u pastes a URL: an empty address is told so, the name comes from the URL, the branch step is skipped, and a blank name keeps the source's", () => | |
| 211 | withTui(alice, async ({ tui, terminal }) => { | |
| 212 | terminal.feed("Mu"); | |
| 213 | await settleApp(tui); | |
| 214 | terminal.feed("\r"); | |
| 215 | await settleApp(tui); | |
| 216 | expect(tui.screen.toText()).toContain("Paste an address first."); | |
| 217 | terminal.feed(`${base}/octo/pub.git\r`); | |
| 218 | await settleApp(tui); | |
| 219 | const form = tui.screen.toText(); | |
| 220 | expect(form).toContain(`from ${base}/octo/pub.git`); | |
| 221 | expect(form).not.toContain("Branch of"); | |
| 222 | expect(form).not.toContain("· branch "); | |
| 223 | expect(form).toContain("pub"); | |
| 224 | terminal.feed("\x7f\x7f\x7f"); // clear the name | |
| 225 | terminal.feed("\x04"); | |
| 226 | await until(() => getRepo(app.db, "alice", "pub") !== undefined); | |
| 227 | await until(() => tui.screen.toText().includes("alice/pub")); | |
| 228 | })); | |
| 229 | ||
| 230 | test("a refusal is shown on the form and nothing is made; a clone that fails shows on the card", () => | |
| 231 | withTui(alice, async ({ tui, terminal }) => { | |
| 232 | terminal.feed("Mu"); | |
| 233 | await settleApp(tui); | |
| 234 | terminal.feed(`${base}/octo/missing.git\r`); | |
| 235 | await settleApp(tui); | |
| 236 | terminal.feed("\x7f\x7f\x7f\x7f\x7f\x7f\x7f"); // "missing" → "" | |
| 237 | terminal.feed("a b!"); | |
| 238 | terminal.feed("\x04"); | |
| 239 | await settleApp(tui); | |
| 240 | expect(tui.screen.toText()).toContain("Names start with a letter or digit"); | |
| 241 | expect(getRepo(app.db, "alice", "a b!")).toBeUndefined(); | |
| 242 | ||
| 243 | // Fix the name: the clone itself cannot work, so the job fails. | |
| 244 | terminal.feed("\x7f\x7f\x7f"); // "a b!" → "" | |
| 245 | terminal.feed("gone"); | |
| 246 | terminal.feed("\x04"); | |
| 247 | await until(() => tui.screen.toText().includes("could not be read") || tui.screen.toText().includes("\u2717")); | |
| 248 | await settleApp(tui); | |
| 249 | const card = tui.screen.toText(); | |
| 250 | expect(card).toContain("Migrating"); | |
| 251 | expect(card).toContain("\u2717"); | |
| 252 | expect(getRepo(app.db, "alice", "gone")).toBeUndefined(); | |
| 253 | })); | |
| 254 | ||
| 255 | test("signed out: M does nothing and the palette does not offer it", () => | |
| 256 | withTui(undefined, async ({ tui, terminal }) => { | |
| 257 | terminal.feed("M"); | |
| 258 | await settleApp(tui); | |
| 259 | expect(tui.screen.toText()).not.toContain("Migrate a repository"); | |
| 260 | const ids = forgeCommands(new Session(app, { sshUsername: "git", remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user: undefined }))({} as never).map((c) => c.id); | |
| 261 | expect(ids).not.toContain("migrate"); | |
| 262 | expect(forgeCommands(new Session(app, identity(alice)))({} as never).map((c) => c.id)).toContain("migrate"); | |
| 263 | })); | |
| 264 | ||
| 265 | test("a repository with no branches, and an origin that cannot be read, are told in words", () => | |
| 266 | withTui(alice, async ({ tui, terminal }) => { | |
| 267 | terminal.feed("M"); | |
| 268 | await settleApp(tui); | |
| 269 | terminal.feed("/empty\r\r"); | |
| 270 | await settleApp(tui); | |
| 271 | expect(tui.screen.toText()).toContain("octo/empty has no branches"); | |
| 272 | ||
| 273 | terminal.feed("/\x7f\x7f\x7f\x7f\x7f\x7f"); // clear the filter, then ask for flaky | |
| 274 | terminal.feed("\x1b"); | |
| 275 | await settleApp(tui); | |
| 276 | terminal.feed("/flaky\r"); | |
| 277 | fake.state.rateLimitFor = 50; | |
| 278 | terminal.feed("\r"); | |
| 279 | await settleApp(tui); | |
| 280 | fake.state.rateLimitFor = 0; | |
| 281 | expect(tui.screen.toText()).toContain("Could not reach the origin. Try again."); | |
| 282 | })); | |
| 283 | ||
| 284 | test("an origin that does not answer is named, and the others are still listed", async () => { | |
| 285 | const erin = await createUser(app.db, { origin: "seed", trusted: true, username: "erin", email: "[email protected]" }); | |
| 286 | if (!erin.ok) throw new Error(String(erin.error)); | |
| 287 | promote(app.db, erin.value); | |
| 288 | const down = serveFakeForge(newFakeState("forgejo", "ghost", "tok-ghost")); | |
| 289 | try { | |
| 290 | for (const [host, token] of [[fake.url, secretToken], [down.url, "tok-ghost"]] as const) { | |
| 291 | const connected = await connectOrigin(app, erin.value, { host, token }); | |
| 292 | if (!connected.ok) throw new Error(connected.error); | |
| 293 | } | |
| 294 | down.state.rateLimitFor = 1000; | |
| 295 | await withTui(erin.value, async ({ tui, terminal }) => { | |
| 296 | terminal.feed("M"); | |
| 297 | await settleApp(tui); | |
| 298 | const text = tui.screen.toText(); | |
| 299 | expect(text).toContain("octo/widgets"); | |
| 300 | expect(text).toContain("Could not reach ghost. The others are listed."); | |
| 301 | }); | |
| 302 | } finally { | |
| 303 | down.stop(); | |
| 304 | } | |
| 305 | }); | |
| 306 | }); |
new above
offline
watchingis still unassigned if subscribe replays an already-finished job synchronously, sowatching()throws here. Use aletand guard the call, or unsubscribe after subscribe returns.