Public: anyone can read this; the crew can change it.
A stranger's front door is the studio: the apps, as they look #46
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/42-a-stranger-s-front-door-is-the-studio-th → main 1 commit fork
4 files
changed +252
−10
docs/STUDIO.md +8
| @@ -91,6 +91,14 @@ whether it is live, what the crew last did there, and a way in. **New app** | ||
| 91 | 91 | is the same flow as ever. `/?mode=dev` is the repositories list without |
| 92 | 92 | switching the browser. |
| 93 | 93 | |
| 94 | Signed out, `/` in the studio is a stranger's front door, not the dev | |
| 95 | pitch: a shelf of the public apps actually serving — name, one line of | |
| 96 | description, live state, and production's own picture, linking straight to | |
| 97 | the app. Sign in and Create an account stay one click away; `/?mode=dev` | |
| 98 | still renders the dev landing unchanged. A forge with no public apps yet — | |
| 99 | most people's first visit — falls back to that same dev landing rather | |
| 100 | than showing an empty shelf. | |
| 101 | ||
| 94 | 102 | ## What the studio never does |
| 95 | 103 | |
| 96 | 104 | It never says _shipped_ before production redeployed, never hides that a |
src/web/pages/site.ts +112 −9
| @@ -10,6 +10,8 @@ | ||
| 10 | 10 | |
| 11 | 11 | import { createRepo } from "../../actions/repos.ts"; |
| 12 | 12 | import { signUp } from "../../actions/users.ts"; |
| 13 | import { appsRuntime } from "../../apps/runtime.ts"; | |
| 14 | import { appsForRepos } from "../../core/apps.ts"; | |
| 13 | 15 | import { listEventsVisibleTo, type ForgeEvent } from "../../core/events.ts"; |
| 14 | 16 | import { |
| 15 | 17 | getIssuesByRef, |
| @@ -17,6 +19,7 @@ import { | ||
| 17 | 19 | listOpenAgentPullsVisibleTo, |
| 18 | 20 | type Issue, |
| 19 | 21 | } from "../../core/issues.ts"; |
| 22 | import { LOOK_SIZES, looksForApps } from "../../core/looks.ts"; | |
| 20 | 23 | import { accessAllows, listOrgsForUser, orgAccess } from "../../core/orgs.ts"; |
| 21 | 24 | import { listPins, pinnedRepoIds } from "../../core/pins.ts"; |
| 22 | 25 | import { |
| @@ -39,10 +42,17 @@ import { crossOrigin, type Ctx } from "../../http/context.ts"; | ||
| 39 | 42 | import { studioHome } from "./studio.ts"; |
| 40 | 43 | import { edgeLoginUrl } from "../../http/edge.ts"; |
| 41 | 44 | import { sshCommand } from "../../ssh/command.ts"; |
| 42 | import { hostOf, notFoundPage, pageHead, signInFirst } from "../chrome.ts"; | |
| 45 | import { | |
| 46 | hostOf, | |
| 47 | notFoundPage, | |
| 48 | pageHead, | |
| 49 | repoBase, | |
| 50 | signInFirst, | |
| 51 | } from "../chrome.ts"; | |
| 43 | 52 | import { field, readForm } from "../form.ts"; |
| 44 | 53 | import { html, when, type Html } from "../html.ts"; |
| 45 | 54 | import { icon, type IconName } from "../icons.ts"; |
| 55 | import { lookUrl } from "./looks.ts"; | |
| 46 | 56 | import { |
| 47 | 57 | eventActor, |
| 48 | 58 | eventVerb, |
| @@ -54,6 +64,7 @@ import { | ||
| 54 | 64 | btn, |
| 55 | 65 | card, |
| 56 | 66 | empty, |
| 67 | envStateBadge, | |
| 57 | 68 | field as formField, |
| 58 | 69 | iconBtn, |
| 59 | 70 | listCard, |
| @@ -509,6 +520,96 @@ function orgsCard(ctx: Ctx, user: User): Html { | ||
| 509 | 520 | ); |
| 510 | 521 | } |
| 511 | 522 | |
| 523 | /** | |
| 524 | * The studio's front door for a stranger: the public apps actually | |
| 525 | * serving, as a shelf of pictures, name and one clamped line — not the dev | |
| 526 | * pitch, which a signed-out reader reaches through `?mode=dev` (unchanged, | |
| 527 | * `landing` below) exactly as a signed-in one reaches it through the mode | |
| 528 | * switch. A forge with nothing public up yet — most people's first visit — | |
| 529 | * falls back to that same dev landing rather than showing an empty shelf. | |
| 530 | * | |
| 531 | * Two grouped reads carry the whole shelf (the apps, then their looks), and | |
| 532 | * a repository's runtime state is a map lookup (`AppsRuntime.status`), not | |
| 533 | * a process call — so this never gets slower as the forge grows apps, the | |
| 534 | * one page anonymous traffic actually hits. | |
| 535 | */ | |
| 536 | function studioLanding(ctx: Ctx): Html { | |
| 537 | const db = ctx.app.db; | |
| 538 | const publicRepos = listReposVisibleTo(db, undefined, { limit: 24 }); | |
| 539 | const records = appsForRepos( | |
| 540 | db, | |
| 541 | publicRepos.map((repo) => repo.id), | |
| 542 | ); | |
| 543 | const appRepos = publicRepos.filter((repo) => records.has(repo.id)); | |
| 544 | if (appRepos.length === 0) return landing(ctx); | |
| 545 | ||
| 546 | const runtime = appsRuntime(ctx.app); | |
| 547 | const looks = looksForApps( | |
| 548 | db, | |
| 549 | appRepos.map((repo) => records.get(repo.id)!.id), | |
| 550 | ); | |
| 551 | const tiles = appRepos.map((repo) => { | |
| 552 | const record = records.get(repo.id)!; | |
| 553 | const production = ( | |
| 554 | runtime === undefined ? [] : runtime.status(record) | |
| 555 | ).find((env) => env.env === "production"); | |
| 556 | const look = looks.find( | |
| 557 | (l) => | |
| 558 | l.appId === record.id && | |
| 559 | l.env === "production" && | |
| 560 | l.kind === "desktop" && | |
| 561 | l.error === null, | |
| 562 | ); | |
| 563 | const base = repoBase(repo); | |
| 564 | return html`<a class="app-tile" href="${base}"> | |
| 565 | ${look | |
| 566 | ? html`<img | |
| 567 | class="app-tile-look" | |
| 568 | src="${lookUrl(base, "production", look.commitOid, "desktop")}" | |
| 569 | width="${LOOK_SIZES.desktop.width}" | |
| 570 | height="${LOOK_SIZES.desktop.height}" | |
| 571 | alt="" | |
| 572 | loading="lazy" | |
| 573 | />` | |
| 574 | : html`<span class="app-tile-blank" aria-hidden="true" | |
| 575 | >${icon("rocket")}</span | |
| 576 | >`} | |
| 577 | <span class="app-tile-body"> | |
| 578 | <span class="app-tile-name">${repo.name}</span> | |
| 579 | ${repo.description !== "" | |
| 580 | ? html`<span class="app-tile-desc truncate" | |
| 581 | >${repo.description}</span | |
| 582 | >` | |
| 583 | : null} | |
| 584 | ${envStateBadge(production?.state ?? "stopped")} | |
| 585 | </span> | |
| 586 | </a>`; | |
| 587 | }); | |
| 588 | ||
| 589 | return html`<section class="hero"> | |
| 590 | <h1>Apps, live.</h1> | |
| 591 | <p> | |
| 592 | A software forge that builds and serves its own apps — these are | |
| 593 | running here right now. | |
| 594 | </p> | |
| 595 | <div class="bar"> | |
| 596 | ${ctx.app.config.edgeToken === undefined | |
| 597 | ? html`${btn("Create an account", { | |
| 598 | href: "/signup", | |
| 599 | variant: "primary", | |
| 600 | size: "lg", | |
| 601 | })} | |
| 602 | ${btn("Sign in", { href: "/signin", size: "lg" })}` | |
| 603 | : btn("Sign in", { | |
| 604 | href: edgeLoginUrl("/"), | |
| 605 | variant: "primary", | |
| 606 | size: "lg", | |
| 607 | })} | |
| 608 | </div> | |
| 609 | </section> | |
| 610 | <div class="mt app-shelf">${tiles}</div>`; | |
| 611 | } | |
| 612 | ||
| 512 | 613 | function landing(ctx: Ctx): Html { |
| 513 | 614 | const publicRepos = listReposVisibleTo(ctx.app.db, undefined, { limit: 8 }); |
| 514 | 615 | return html`<section class="hero"> |
| @@ -671,17 +772,19 @@ function dashboard(ctx: Ctx, user: User): Html { | ||
| 671 | 772 | export async function home(ctx: Ctx): Promise<Response> { |
| 672 | 773 | const user = ctx.auth.user; |
| 673 | 774 | // Studio by default: apps, the crew, what asks you. `?mode=dev` on the |
| 674 | // home is the one-off way in without switching the browser. | |
| 675 | if ( | |
| 676 | user !== undefined && | |
| 677 | ctx.mode === "studio" && | |
| 678 | ctx.url.searchParams.get("mode") !== "dev" | |
| 679 | ) | |
| 680 | return await studioHome(ctx, user); | |
| 775 | // home is the one-off way in without switching the browser — for a | |
| 776 | // stranger as much as for someone signed in. | |
| 777 | const studio = | |
| 778 | ctx.mode === "studio" && ctx.url.searchParams.get("mode") !== "dev"; | |
| 779 | if (user !== undefined && studio) return await studioHome(ctx, user); | |
| 681 | 780 | return page({ |
| 682 | 781 | ctx, |
| 683 | 782 | title: "forge", |
| 684 | body: user ? dashboard(ctx, user) : landing(ctx), | |
| 783 | body: user | |
| 784 | ? dashboard(ctx, user) | |
| 785 | : studio | |
| 786 | ? studioLanding(ctx) | |
| 787 | : landing(ctx), | |
| 685 | 788 | ...(user === undefined ? {} : { scriptJs: liveClientJs }), |
| 686 | 789 | }); |
| 687 | 790 | } |
src/web/style/content.ts +19
| @@ -269,6 +269,25 @@ a.origin-chip:hover { border-color: var(--border-strong); color: var(--fg); } | ||
| 269 | 269 | .hero-points .card-body { font-size: 13px; color: var(--muted-fg); } |
| 270 | 270 | .hero-points strong { display: block; color: var(--fg); font-size: 14px; margin-bottom: 3px; } |
| 271 | 271 | |
| 272 | /* The studio's front door: the public apps that are serving, as pictures. */ | |
| 273 | .app-shelf { | |
| 274 | display: grid; gap: 16px; max-width: 1040px; margin-inline: auto; | |
| 275 | grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); | |
| 276 | } | |
| 277 | .app-tile { | |
| 278 | display: block; border: 1px solid var(--border); border-radius: var(--radius); | |
| 279 | overflow: hidden; background: var(--card); color: inherit; text-decoration: none; | |
| 280 | } | |
| 281 | .app-tile:hover { border-color: var(--accent); } | |
| 282 | .app-tile-look { display: block; width: 100%; height: auto; background: var(--muted); } | |
| 283 | .app-tile-blank { | |
| 284 | display: flex; align-items: center; justify-content: center; | |
| 285 | aspect-ratio: 1280 / 800; background: var(--muted); color: var(--muted-fg); | |
| 286 | } | |
| 287 | .app-tile-body { display: grid; gap: 6px; padding: 10px 12px; } | |
| 288 | .app-tile-name { font-weight: 600; font-size: 14px; } | |
| 289 | .app-tile-desc { color: var(--muted-fg); font-size: 12.5px; } | |
| 290 | ||
| 272 | 291 | /* Every page grid, in one rule: a grid item's automatic minimum size is its |
| 273 | 292 | *content*, so a column with a long unbreakable line pushes the page wider |
| 274 | 293 | than the phone rather than letting its own ellipsis do the work. A zero |
test/studio.test.ts +113 −1
| @@ -3,15 +3,18 @@ | ||
| 3 | 3 | * URLs answer in both modes; the cookie decides which face they show. |
| 4 | 4 | */ |
| 5 | 5 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| 6 | import { mkdirSync, writeFileSync } from "node:fs"; | |
| 6 | 7 | import { mkdtemp, rm } from "node:fs/promises"; |
| 7 | 8 | import { tmpdir } from "node:os"; |
| 8 | import { join } from "node:path"; | |
| 9 | import { dirname, join } from "node:path"; | |
| 9 | 10 | import { createApp, type App } from "../src/app.ts"; |
| 10 | 11 | import { enableApp } from "../src/actions/apps.ts"; |
| 11 | 12 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; |
| 12 | 13 | import { openPull } from "../src/actions/pulls.ts"; |
| 13 | 14 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; |
| 15 | import { getAppByRepo } from "../src/core/apps.ts"; | |
| 14 | 16 | import { getIssue, listIssues } from "../src/core/issues.ts"; |
| 17 | import { recordLook } from "../src/core/looks.ts"; | |
| 15 | 18 | import { getPull, type Pull } from "../src/core/pulls.ts"; |
| 16 | 19 | import type { Repo } from "../src/core/repos.ts"; |
| 17 | 20 | import { claimRun, createRun, finishRun } from "../src/core/runs.ts"; |
| @@ -21,6 +24,12 @@ import { CHECK_FAILED_LIMIT } from "../src/crew/dispatch.ts"; | ||
| 21 | 24 | import { serve, type Servers } from "../src/http/server.ts"; |
| 22 | 25 | import { pendingLeans } from "../src/web/pages/studio.ts"; |
| 23 | 26 | |
| 27 | /** One transparent pixel, dropped where a look would sit. */ | |
| 28 | const PNG = Buffer.from( | |
| 29 | "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", | |
| 30 | "base64", | |
| 31 | ); | |
| 32 | ||
| 24 | 33 | describe("the studio", () => { |
| 25 | 34 | test("the asks script posts to the form's action attribute: a control named action shadows form.action", async () => { |
| 26 | 35 | const { asksJs } = await import("../src/web/client.ts"); |
| @@ -376,6 +385,109 @@ describe("the studio", () => { | ||
| 376 | 385 | expect(after).not.toContain('class="studio-ask-pick"'); |
| 377 | 386 | expect(after).not.toContain('action="/studio/asks"'); |
| 378 | 387 | }); |
| 388 | ||
| 389 | test("signed out in studio, / leads with the public apps that are serving — their look, not a private one's", async () => { | |
| 390 | // A second app, public, with a picture of its production. "notes" stays | |
| 391 | // private, so its presence (or absence) is the negative case below. | |
| 392 | const madePublic = await createRepo(app, alice, { | |
| 393 | name: "beacon", | |
| 394 | visibility: "public", | |
| 395 | description: "a lighthouse in the fog", | |
| 396 | }); | |
| 397 | if (!madePublic.ok) throw new Error(String(madePublic.error)); | |
| 398 | const beacon = madePublic.value; | |
| 399 | const seededPublic = await commitToBranch(app, alice, beacon, { | |
| 400 | message: "Start", | |
| 401 | files: [ | |
| 402 | { | |
| 403 | path: "package.json", | |
| 404 | content: '{"name":"beacon","scripts":{"start":"bun run server.ts"}}\n', | |
| 405 | }, | |
| 406 | { | |
| 407 | path: "server.ts", | |
| 408 | content: | |
| 409 | "Bun.serve({ port: Number(process.env.PORT), fetch: () => new Response('ok') });\n", | |
| 410 | }, | |
| 411 | ], | |
| 412 | }); | |
| 413 | if (!seededPublic.ok) throw new Error(JSON.stringify(seededPublic.error)); | |
| 414 | const enabledPublic = await enableApp(app, alice, beacon, { | |
| 415 | slug: "beacon", | |
| 416 | }); | |
| 417 | if (!enabledPublic.ok) throw new Error(String(enabledPublic.error)); | |
| 418 | ||
| 419 | // A look for each app's production, so the picture route has something | |
| 420 | // real to answer for and refuse. | |
| 421 | const dropLook = (appId: string, env: "production", commit: string) => { | |
| 422 | const rel = join("looks", appId, `${env}-desktop.png`); | |
| 423 | const abs = join(app.config.dataDir, rel); | |
| 424 | mkdirSync(dirname(abs), { recursive: true }); | |
| 425 | writeFileSync(abs, PNG); | |
| 426 | return recordLook(app.db, { | |
| 427 | appId, | |
| 428 | env, | |
| 429 | commitOid: commit, | |
| 430 | kind: "desktop", | |
| 431 | path: rel, | |
| 432 | }); | |
| 433 | }; | |
| 434 | const beaconApp = getAppByRepo(app.db, beacon.id)!; | |
| 435 | const notesApp = getAppByRepo(app.db, repo.id)!; | |
| 436 | dropLook(beaconApp.id, "production", seededPublic.value.commitOid); | |
| 437 | dropLook(notesApp.id, "production", "0".repeat(40)); | |
| 438 | ||
| 439 | // Signed out, with the studio cookie: the public app's name, its look, | |
| 440 | // and Sign in / Create an account — never the private one. | |
| 441 | const anon = await ( | |
| 442 | await fetch(`${base}/`, { headers: { cookie: "forge_mode=studio" } }) | |
| 443 | ).text(); | |
| 444 | expect(anon).toContain("beacon"); | |
| 445 | expect(anon).toContain("a lighthouse in the fog"); | |
| 446 | expect(anon).toContain(`/looks/production/${seededPublic.value.commitOid}/desktop.png`); | |
| 447 | expect(anon).toContain('href="/signin"'); | |
| 448 | expect(anon).toContain('href="/signup"'); | |
| 449 | expect(anon).not.toContain(">notes<"); | |
| 450 | expect(anon).not.toContain("Your code, your machine"); | |
| 451 | ||
| 452 | // The private app's picture 404s for a stranger — asserted, not assumed. | |
| 453 | const privateLook = await fetch( | |
| 454 | `${base}/alice/notes/looks/production/${"0".repeat(40)}/desktop.png`, | |
| 455 | { headers: { cookie: "forge_mode=studio" } }, | |
| 456 | ); | |
| 457 | expect(privateLook.status).toBe(404); | |
| 458 | // The public one's does not. | |
| 459 | const publicLook = await fetch( | |
| 460 | `${base}/alice/beacon/looks/production/${seededPublic.value.commitOid}/desktop.png`, | |
| 461 | { headers: { cookie: "forge_mode=studio" } }, | |
| 462 | ); | |
| 463 | expect(publicLook.status).toBe(200); | |
| 464 | ||
| 465 | // `?mode=dev` still renders the dev landing, unchanged, for a stranger. | |
| 466 | const dev = await ( | |
| 467 | await fetch(`${base}/?mode=dev`, { | |
| 468 | headers: { cookie: "forge_mode=studio" }, | |
| 469 | }) | |
| 470 | ).text(); | |
| 471 | expect(dev).toContain("Your code, your machine"); | |
| 472 | }); | |
| 473 | ||
| 474 | test("a forge with no public apps falls back to the dev landing, signed out in studio", async () => { | |
| 475 | const dataDir2 = await mkdtemp(join(tmpdir(), "forge-studio-empty-")); | |
| 476 | const app2 = createApp({ dataDir: dataDir2, port: 0 }); | |
| 477 | const servers2 = await serve(app2); | |
| 478 | try { | |
| 479 | const body = await ( | |
| 480 | await fetch(`${servers2.url}/`, { | |
| 481 | headers: { cookie: "forge_mode=studio" }, | |
| 482 | }) | |
| 483 | ).text(); | |
| 484 | expect(body).toContain("Your code, your machine"); | |
| 485 | } finally { | |
| 486 | servers2.stop(); | |
| 487 | app2.close(); | |
| 488 | await rm(dataDir2, { recursive: true, force: true }); | |
| 489 | } | |
| 490 | }); | |
| 379 | 491 | }); |
| 380 | 492 | |
| 381 | 493 | describe("the studio home does not wait on the decider", () => { |
new above
offline