Public: anyone can read this; the crew can change it.
A Small forge (1 CPU, 2 GiB) builds a first app: today the host stops it for… #11979
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11975-a-small-forge-1-cpu-2-gib-builds-a-first → main 10 commits fork 2 threads · 2 unresolved
3 files
changed +138
−0
docs/features/ssh-first-ask.md added +8
| @@ -0,0 +1,8 @@ | ||
| 1 | # The first ask by ssh makes an app (#11975) | |
| 2 | ||
| 3 | On a forge spawned for a person, the first `ssh <forge> tell <words>` from someone who has not told the crew yet makes a new app from the words and files the ask there (the same `firstAsk` the home page uses), instead of filing it on the forge's own source repository. It prints the new issue's address. | |
| 4 | ||
| 5 | - **Only the first:** once the person has any issue of their own, `tell` goes to the forge's repository as before. | |
| 6 | - **Not on an app's login:** `ssh <app>@<forge> tell` still goes to that app. | |
| 7 | - **Not on other forges:** a forge that was not spawned (no `parent.json`) is unchanged. | |
| 8 | - Code: `src/tui/ssh-tell.ts`; test: `test/ssh-tell-first-ask.test.ts`. |
src/tui/ssh-tell.ts +24
| @@ -20,6 +20,9 @@ import type { User } from "../core/users.ts"; | ||
| 20 | 20 | import type { EnvName } from "../apps/data.ts"; |
| 21 | 21 | import type { SshSession } from "../ssh/server.ts"; |
| 22 | 22 | import { tellAnswer } from "./screens/tell.ts"; |
| 23 | import { AUTONOMY_FAILED_WORDS, firstAsk } from "../actions/first_ask.ts"; | |
| 24 | import { nextStepsFor } from "../core/next-steps.ts"; | |
| 25 | import { crewCanRun } from "../web/studio/crew_key.ts"; | |
| 23 | 26 | |
| 24 | 27 | const CRLF = "\r\n"; |
| 25 | 28 | |
| @@ -78,6 +81,9 @@ async function told( | ||
| 78 | 81 | if (/[\x00-\x1f\x7f-\x9f\u2028\u2029]/.test(words)) return refuse(session, CONTROL_WORDS); |
| 79 | 82 | const said = words.trim(); |
| 80 | 83 | if (said === "") return refuse(session, SAY_SOMETHING_WORDS); |
| 84 | // A claimed forge's first ask makes a new app, as the page's does: | |
| 85 | // never an issue on the forge's own source. | |
| 86 | if (appRepo === undefined && (await isFirstAsk(app, user))) return firstTold(app, session, user, said); | |
| 81 | 87 | const [owner = "", name = ""] = (app.config.forgeRepo ?? "").split("/"); |
| 82 | 88 | const repo = appRepo ?? getRepo(app.db, owner, name); |
| 83 | 89 | if (repo === undefined) return refuse(session, NO_CREW_WORDS); |
| @@ -90,3 +96,21 @@ async function told( | ||
| 90 | 96 | ); |
| 91 | 97 | session.exit(0); |
| 92 | 98 | } |
| 99 | ||
| 100 | /** Whether this is the person's first ask on a forge spawned for them: it makes an app. */ | |
| 101 | async function isFirstAsk(app: App, user: User): Promise<boolean> { | |
| 102 | const steps = await nextStepsFor(app, user); | |
| 103 | return steps?.some((s) => s.key === "tell" && !s.done) === true; | |
| 104 | } | |
| 105 | ||
| 106 | async function firstTold(app: App, session: SshSession, user: User, said: string): Promise<void> { | |
| 107 | const first = await firstAsk(app, user, said, crewCanRun(app)); | |
| 108 | if (!first.ok) return refuse(session, first.error.words); | |
| 109 | const { repo, told, autonomyFailed } = first.value; | |
| 110 | if (!told.ok) return refuse(session, tellAnswer(told).text); | |
| 111 | const base = app.config.url ?? `http://localhost:${app.config.port ?? 3000}`; | |
| 112 | session.write( | |
| 113 | `${tellAnswer(told).text}${CRLF}${autonomyFailed ? `${AUTONOMY_FAILED_WORDS}${CRLF}` : ""}${base}/${repo.ownerName}/${repo.name}/issues/${told.value.issue.number}${CRLF}`, | |
| 114 | ); | |
| 115 | session.exit(0); | |
| 116 | } |
test/ssh-tell-first-ask.test.ts added +106
| @@ -0,0 +1,106 @@ | ||
| 1 | /** | |
| 2 | * `ssh <forge> tell` as a spawned forge's first ask (#11975): it makes a new | |
| 3 | * app and files the ask there, never on the forge's own source; the next tell | |
| 4 | * goes to the forge's repository as before. | |
| 5 | */ | |
| 6 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 7 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { createApp, type App } from "../src/app.ts"; | |
| 11 | import { createRepo } from "../src/actions/repos.ts"; | |
| 12 | import { createOrg } from "../src/core/orgs.ts"; | |
| 13 | import { addKey, fingerprint, parseAuthorizedKey } from "../src/core/keys.ts"; | |
| 14 | import { createUser } from "../src/core/users.ts"; | |
| 15 | import { forgeSshSession } from "../src/tui/ssh.ts"; | |
| 16 | import type { SshSession } from "../src/ssh/server.ts"; | |
| 17 | import { quietLogs } from "./helpers/globals.ts"; | |
| 18 | ||
| 19 | quietLogs(); | |
| 20 | ||
| 21 | const KEY = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIB2S7bF0R4NmuTUvVJXPPjdI1AF8XHXeYSNfr8DJ5eDo carol"; | |
| 22 | ||
| 23 | let dir = ""; | |
| 24 | let app: App; | |
| 25 | ||
| 26 | const exec = async (command: string) => { | |
| 27 | const parsed = parseAuthorizedKey(KEY); | |
| 28 | if (!parsed.ok) throw new Error("key"); | |
| 29 | let out = ""; | |
| 30 | let err = ""; | |
| 31 | let code: number | undefined; | |
| 32 | await new Promise<void>((resolve) => { | |
| 33 | const session: SshSession = { | |
| 34 | key: { algo: parsed.value.algo, blob: parsed.value.blob, fingerprint: fingerprint(parsed.value.blob), line: KEY }, | |
| 35 | username: "whoever", | |
| 36 | remoteAddress: "203.0.113.9", | |
| 37 | kind: "exec", | |
| 38 | command, | |
| 39 | term: undefined, | |
| 40 | env: {}, | |
| 41 | cols: 0, | |
| 42 | rows: 0, | |
| 43 | widthPx: 0, | |
| 44 | heightPx: 0, | |
| 45 | ended: false, | |
| 46 | pending: 0, | |
| 47 | write: (d: Uint8Array | string) => void (out += typeof d === "string" ? d : Buffer.from(d).toString()), | |
| 48 | writeStderr: (d: string) => void (err += d), | |
| 49 | onData() {}, | |
| 50 | onResize() {}, | |
| 51 | onEof() {}, | |
| 52 | onClose() {}, | |
| 53 | exit(c: number) { | |
| 54 | code ??= c; | |
| 55 | resolve(); | |
| 56 | }, | |
| 57 | drain: async () => {}, | |
| 58 | }; | |
| 59 | forgeSshSession(app)(session); | |
| 60 | }); | |
| 61 | return { out, err, code }; | |
| 62 | }; | |
| 63 | ||
| 64 | const issuesOn = (name: string) => | |
| 65 | app.db | |
| 66 | .query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM issue WHERE repo_id = (SELECT id FROM repo WHERE name = ?1)") | |
| 67 | .get(name)!.n; | |
| 68 | ||
| 69 | beforeAll(async () => { | |
| 70 | dir = await mkdtemp(join(tmpdir(), "forge-ssh-first-")); | |
| 71 | app = createApp({ dataDir: dir, port: 0, forgeRepo: "carol/forge", url: "https://forge.test" }); | |
| 72 | const u = await createUser(app.db, { | |
| 73 | origin: "seed", | |
| 74 | trusted: true, | |
| 75 | username: "carol", | |
| 76 | email: "[email protected]", | |
| 77 | password: "a-good-passphrase", | |
| 78 | adminIfFirst: true, | |
| 79 | }); | |
| 80 | if (!u.ok) throw new Error(u.error); | |
| 81 | const parsed = parseAuthorizedKey(KEY); | |
| 82 | if (!parsed.ok) throw new Error("key"); | |
| 83 | addKey(app.db, u.value.id, "laptop", parsed.value); | |
| 84 | const org = createOrg(app.db, "kin", u.value.id); | |
| 85 | if (!org.ok) throw new Error(org.error); | |
| 86 | await Bun.write(join(dir, "parent.json"), JSON.stringify({ org: "kin" })); | |
| 87 | const made = await createRepo(app, u.value, { owner: "carol", name: "forge" }); | |
| 88 | if (!made.ok) throw new Error(made.error); | |
| 89 | }); | |
| 90 | ||
| 91 | afterAll(async () => { | |
| 92 | app.close(); | |
| 93 | await rm(dir, { recursive: true, force: true }); | |
| 94 | }); | |
| 95 | ||
| 96 | test("the first tell makes a new app and files there; the second goes to the forge's repository", async () => { | |
| 97 | const first = await exec("tell a weekly menu page for the bakery"); | |
| 98 | expect(first.code).toBe(0); | |
| 99 | expect(first.out).toContain("/carol/"); | |
| 100 | expect(first.out).not.toContain("/carol/forge/"); | |
| 101 | expect(issuesOn("forge")).toBe(0); | |
| 102 | const second = await exec("tell make it blue"); | |
| 103 | expect(second.code).toBe(0); | |
| 104 | expect(second.out).toContain("/carol/forge/issues/"); | |
| 105 | expect(issuesOn("forge")).toBe(1); | |
| 106 | }); |
new above
offline
Merge with the import on the line above (one import per module). The first-ask path also needs a docs/features how-to.