Public: anyone can read this; the crew can change it.
A machine shell opens to a greeting, a prompt with the repository and branch, and history that persists #10611
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/10547-a-machine-shell-opens-to-a-greeting-a-pr → main 5 commits fork 5 threads · 5 unresolved
5 files
changed +468
−19
docs/features/machine-shell-rc.md added +9
| @@ -0,0 +1,9 @@ | ||
| 1 | # A machine shell's first screen | |
| 2 | ||
| 3 | The shell opens in the person's own checkout of the repository (cloned once from the bare repo, kept between sessions), with bun and git on the path. It prints a greeting: which repository and machine, how to leave (detach with Ctrl-b d to keep it running, `exit` to end it) and the app's own `tty` command when forge.json has one. The prompt shows `owner/name:branch`. History is `.forge_history` in the person's home under `FORGE_APP_DATA/machines/<repo>/<machine>/<person>/home`, written after every command. | |
| 4 | ||
| 5 | The rc file is the constant `RC` in `src/machines/rc.ts`; nothing from a request reaches it. | |
| 6 | ||
| 7 | The checkout is cloned when the shell is first opened, off the event loop. Two connections at once share one clone. git gets 30 seconds (`CLONE_MS`), then it is killed; at most four first clones (`MAX_CLONES`) run across the forge at once. A clone that fails, runs out of time or finds no room leaves nothing behind (it is made in `checkout.partial` and renamed only when complete), the next connection tries again, and the greeting says the shell starts in home instead. Detach is only mentioned when tmux holds the session. | |
| 8 | ||
| 9 | Without bash on the machine, `/bin/sh` still prints the greeting, starts in the checkout and shows the repository in the prompt; it has no branch in the prompt and no persistent history, because sh has neither. |
src/http/server.ts +6 −3
| @@ -579,7 +579,7 @@ import { cellsAvailable } from "../apps/contain.ts"; | ||
| 579 | 579 | import { machineShellPage, machinesSettingsPage } from "../web/pages/machines.ts"; |
| 580 | 580 | import { machineTermAsset, wasmResponse, ghosttyWasmAsset } from "../web/machine-term-asset.ts"; |
| 581 | 581 | import { |
| 582 | defaultCommand, | |
| 582 | defaultShell, | |
| 583 | 583 | mayOpenShell, |
| 584 | 584 | setShellOptions, |
| 585 | 585 | shellOptions, |
| @@ -2612,12 +2612,15 @@ export async function serve(app: App): Promise<Servers> { | ||
| 2612 | 2612 | // first shell rather than at boot; a test that set its own keeps them. |
| 2613 | 2613 | if (shellOptions(app) === undefined) { |
| 2614 | 2614 | const cells = await cellsAvailable(); |
| 2615 | if (shellOptions(app) === undefined) | |
| 2615 | if (shellOptions(app) === undefined) { | |
| 2616 | const plan = defaultShell(); | |
| 2616 | 2617 | setShellOptions(app, { |
| 2617 | 2618 | idleMs: () => shellIdleMs(app.db), |
| 2618 | 2619 | contain: cells.ok ? "cell" : "refuse", |
| 2619 | command: defaultCommand(), | |
| 2620 | command: plan.command, | |
| 2621 | detaches: plan.detaches, | |
| 2620 | 2622 | }); |
| 2623 | } | |
| 2621 | 2624 | } |
| 2622 | 2625 | const data: ShellSocket = { shell: { ...may.grant, app } }; |
| 2623 | 2626 | if (server.upgrade(request, { data })) return undefined; |
src/machines/rc.ts added +166
| @@ -0,0 +1,166 @@ | ||
| 1 | /** | |
| 2 | * What a machine's shell opens to (#10547): a greeting, a prompt with the | |
| 3 | * repository and branch, and history that is there next session. | |
| 4 | * | |
| 5 | * The rc file is a constant in this source, written into the cell's home; no | |
| 6 | * text from a request ever reaches it. What varies — which repository, which | |
| 7 | * machine, the app's own `tty` command — travels as environment variables | |
| 8 | * and a greeting file of plain text the rc only prints (`cat`), never | |
| 9 | * evaluates. The names are validated before they get here; the tty command | |
| 10 | * is stripped of control bytes. | |
| 11 | * | |
| 12 | * History lives in the person's own home, which is under | |
| 13 | * `FORGE_APP_DATA/machines/<repo>/<machine>/<person>/home`: per person, | |
| 14 | * repository and machine, and written after every command so a tab closed | |
| 15 | * mid-session loses nothing. | |
| 16 | */ | |
| 17 | import { mkdirSync, writeFileSync, existsSync, renameSync, rmSync } from "node:fs"; | |
| 18 | import { join } from "node:path"; | |
| 19 | import { gitEnv } from "../git/env.ts"; | |
| 20 | ||
| 21 | export const RC_FILE = ".forge_rc"; | |
| 22 | export const GREETING_FILE = ".forge_greeting"; | |
| 23 | export const HISTORY_FILE = ".forge_history"; | |
| 24 | /** The checkout's directory under the person's workspace. */ | |
| 25 | export const CHECKOUT_DIR = "checkout"; | |
| 26 | ||
| 27 | /** The rc file: constant. Reads FORGE_REPO, FORGE_MACHINE and the greeting file. */ | |
| 28 | export const RC = `# The forge's machine shell rc. Written by the forge; edits are replaced. | |
| 29 | case $- in *i*) ;; *) return ;; esac | |
| 30 | export HISTFILE="$HOME/${HISTORY_FILE}" | |
| 31 | export HISTSIZE=100000 | |
| 32 | export HISTFILESIZE=100000 | |
| 33 | export HISTCONTROL=ignoredups | |
| 34 | shopt -s histappend checkwinsize | |
| 35 | PROMPT_COMMAND='history -a' | |
| 36 | alias ls='ls --color=auto' | |
| 37 | alias ll='ls -lA --color=auto' | |
| 38 | alias la='ls -A --color=auto' | |
| 39 | alias grep='grep --color=auto' | |
| 40 | forge_branch() { | |
| 41 | git symbolic-ref --short -q HEAD 2>/dev/null || git rev-parse --short HEAD 2>/dev/null | |
| 42 | } | |
| 43 | PS1='\\[\\e[1;36m\\]$FORGE_REPO\\[\\e[0m\\]:\\[\\e[1;33m\\]$(forge_branch)\\[\\e[0m\\] \\w \\$ ' | |
| 44 | if [ -d "$FORGE_CHECKOUT" ]; then cd "$FORGE_CHECKOUT"; fi | |
| 45 | if [ -f "$HOME/${GREETING_FILE}" ]; then cat "$HOME/${GREETING_FILE}"; fi | |
| 46 | `; | |
| 47 | ||
| 48 | /** A one-line, printable form of text that may come from a repository's file. */ | |
| 49 | function plain(text: string): string { | |
| 50 | return text.replace(/[^\x20-\x7e]/g, "").slice(0, 200); | |
| 51 | } | |
| 52 | ||
| 53 | export interface GreetingFacts { | |
| 54 | readonly repo: string; | |
| 55 | readonly machine: string; | |
| 56 | /** Whether tmux holds the session, so detaching keeps it. */ | |
| 57 | readonly detaches: boolean; | |
| 58 | /** Whether the checkout exists; without it the shell starts in home and says so. */ | |
| 59 | readonly checkout: boolean; | |
| 60 | /** The app's own `tty` command from forge.json, when it has one. */ | |
| 61 | readonly tty: string | null; | |
| 62 | } | |
| 63 | ||
| 64 | /** The greeting, in plain words: where you are and how to leave. */ | |
| 65 | export function greeting(facts: GreetingFacts): string { | |
| 66 | const leave = facts.detaches | |
| 67 | ? "To leave and keep this shell running, detach: press Ctrl-b then d. To end it, type exit." | |
| 68 | : "To leave, type exit (this shell ends with it)."; | |
| 69 | const lines = [ | |
| 70 | `This is the machine "${plain(facts.machine)}" of ${plain(facts.repo)}. ${facts.checkout ? "You are in its checkout, with bun and git on the path." : "Its checkout could not be made, so you are in your home directory; bun and git are on the path."}`, | |
| 71 | leave, | |
| 72 | ]; | |
| 73 | if (facts.tty !== null && plain(facts.tty) !== "") | |
| 74 | lines.push(`The app's own terminal command: ${plain(facts.tty)}`); | |
| 75 | return `${lines.join("\n")}\n\n`; | |
| 76 | } | |
| 77 | ||
| 78 | /** Where a person's history is kept, under the data directory. */ | |
| 79 | export function historyPath(home: string): string { | |
| 80 | return join(home, HISTORY_FILE); | |
| 81 | } | |
| 82 | ||
| 83 | /** Write the rc and greeting into `home`, and make the history file exist. */ | |
| 84 | export function prepareHome(home: string, facts: GreetingFacts): void { | |
| 85 | mkdirSync(home, { recursive: true }); | |
| 86 | writeFileSync(join(home, RC_FILE), RC); | |
| 87 | writeFileSync(join(home, GREETING_FILE), greeting(facts)); | |
| 88 | if (!existsSync(historyPath(home))) writeFileSync(historyPath(home), ""); | |
| 89 | } | |
| 90 | ||
| 91 | /** How long a first clone may take before git is killed and the shell starts in home. */ | |
| 92 | export const CLONE_MS = 30_000; | |
| 93 | /** First clones running at once across the forge; past it a shell starts in home. */ | |
| 94 | export const MAX_CLONES = 4; | |
| 95 | ||
| 96 | const inflight = new Map<string, Promise<boolean>>(); | |
| 97 | ||
| 98 | /** | |
| 99 | * The person's checkout of the repository, cloned once from the bare | |
| 100 | * repository on the default branch; later sessions find it as they left it. | |
| 101 | * | |
| 102 | * Single-flight per target: a second handshake for the same person and | |
| 103 | * machine waits on the first clone rather than racing it. The clone goes to | |
| 104 | * a sibling directory and is renamed into place only when git finished, so | |
| 105 | * a half-made checkout is never mistaken for one. git is killed after | |
| 106 | * `timeoutMs` (which also bounds how much a huge repository can write), and | |
| 107 | * at most `MAX_CLONES` run at once; either way the answer is no, and the | |
| 108 | * greeting says the shell is in home. The clone runs beside the event loop. | |
| 109 | */ | |
| 110 | export function ensureCheckout(bareDir: string, dir: string, timeoutMs = CLONE_MS): Promise<boolean> { | |
| 111 | const target = join(dir, CHECKOUT_DIR); | |
| 112 | const running = inflight.get(target); | |
| 113 | if (running !== undefined) return running; | |
| 114 | if (existsSync(join(target, ".git"))) return Promise.resolve(true); | |
| 115 | if (inflight.size >= MAX_CLONES) return Promise.resolve(false); | |
| 116 | const flight = clone(bareDir, target, timeoutMs).finally(() => inflight.delete(target)); | |
| 117 | inflight.set(target, flight); | |
| 118 | return flight; | |
| 119 | } | |
| 120 | ||
| 121 | async function clone(bareDir: string, target: string, timeoutMs: number): Promise<boolean> { | |
| 122 | const partial = `${target}.partial`; | |
| 123 | rmSync(partial, { recursive: true, force: true }); | |
| 124 | let code = 1; | |
| 125 | try { | |
| 126 | const proc = Bun.spawn(["git", "clone", "--quiet", "--", bareDir, partial], { | |
| 127 | env: gitEnv(), | |
| 128 | stdin: "ignore", | |
| 129 | stdout: "ignore", | |
| 130 | stderr: "ignore", | |
| 131 | timeout: timeoutMs, | |
| 132 | killSignal: "SIGKILL", | |
| 133 | }); | |
| 134 | code = await proc.exited; | |
| 135 | } catch { | |
| 136 | code = 1; | |
| 137 | } | |
| 138 | if (code === 0 && existsSync(join(partial, ".git"))) { | |
| 139 | rmSync(target, { recursive: true, force: true }); | |
| 140 | renameSync(partial, target); | |
outdated
Dead code: nothing calls shellCommand. Remove it. |
||
| 141 | return true; | |
| 142 | } | |
| 143 | rmSync(partial, { recursive: true, force: true }); | |
| 144 | return false; | |
| 145 | } | |
| 146 | ||
| 147 | /** What the pty runs, and whether leaving it keeps the session (tmux holds it). */ | |
| 148 | export interface ShellPlan { | |
| 149 | readonly command: string; | |
| 150 | readonly detaches: boolean; | |
| 151 | } | |
| 152 | ||
| 153 | /** | |
| 154 | * The shell the pty runs: bash on the rc, in tmux where the machine has it. | |
| 155 | * Without bash, `/bin/sh` still prints the greeting and starts in the | |
| 156 | * checkout with a repository prompt, but has no branch in the prompt and no | |
| 157 | * persistent history (sh has neither). | |
| 158 | */ | |
| 159 | export function shellPlan(hasTmux: boolean, hasBash: boolean): ShellPlan { | |
| 160 | const sh = `if [ -d "$FORGE_CHECKOUT" ]; then cd "$FORGE_CHECKOUT"; fi; ` + | |
| 161 | `if [ -f "$HOME/${GREETING_FILE}" ]; then cat "$HOME/${GREETING_FILE}"; fi; PS1='$FORGE_REPO $ '; export PS1; exec /bin/sh -i`; | |
| 162 | const inner = hasBash ? `bash --rcfile "$HOME/${RC_FILE}" -i` : `/bin/sh -c '${sh.replace(/'/g, "'\\''")}'`; | |
| 163 | return hasTmux | |
| 164 | ? { command: `exec tmux -L forge new-session -A -s shell '${inner.replace(/'/g, "'\\''")}'`, detaches: true } | |
| 165 | : { command: `exec ${inner}`, detaches: false }; | |
| 166 | } |
src/machines/shell.ts +55 −16
| @@ -21,9 +21,9 @@ | ||
| 21 | 21 | * they have been validated. |
| 22 | 22 | */ |
| 23 | 23 | |
| 24 | import { mkdirSync } from "node:fs"; | |
| 24 | import { existsSync, mkdirSync } from "node:fs"; | |
| 25 | 25 | import { cellHides } from "../spawn/hosted-mark.ts"; |
| 26 | import { join } from "node:path"; | |
| 26 | import { dirname, join } from "node:path"; | |
| 27 | 27 | import type { Database } from "bun:sqlite"; |
| 28 | 28 | import type { App } from "../app.ts"; |
| 29 | 29 | import { openCell, resolveLimits, type Cell, type CellSpec } from "../apps/contain.ts"; |
| @@ -38,6 +38,11 @@ import { reachableApps, type Machine } from "../crew/machines.ts"; | ||
| 38 | 38 | import { ownApp, ownAppUrls } from "../crew/app_hosts.ts"; |
| 39 | 39 | import { limitValue } from "../settings/limits.ts"; |
| 40 | 40 | import { id as newId } from "../kernel/id.ts"; |
| 41 | import { readFileAt } from "../apps/checkout.ts"; | |
| 42 | import { parseManifest } from "../apps/manifest.ts"; | |
| 43 | import { resolveOid } from "../git/merge.ts"; | |
| 44 | import { repoDir } from "../git/repos.ts"; | |
| 45 | import { ensureCheckout, prepareHome, shellPlan, CHECKOUT_DIR, type ShellPlan } from "./rc.ts"; | |
| 41 | 46 | |
| 42 | 47 | // ---- the route ---- |
| 43 | 48 | |
| @@ -77,6 +82,8 @@ export interface ShellGrant { | ||
| 77 | 82 | readonly machine: Machine; |
| 78 | 83 | readonly user: User; |
| 79 | 84 | readonly sessionId: string | undefined; |
| 85 | /** The app's own `tty` command at the default branch's tip, for the greeting. */ | |
| 86 | readonly tty?: string | null; | |
| 80 | 87 | } |
| 81 | 88 | |
| 82 | 89 | /** |
| @@ -103,11 +110,25 @@ export async function mayOpenShell( | ||
| 103 | 110 | const config = await repoMachines(app, repo); |
| 104 | 111 | const machine = config.machines.find((m) => m.name === target.machine); |
| 105 | 112 | if (machine === undefined) return { ok: false, refusal: "not_found" }; |
| 106 | const grant = { repo, machine, user, sessionId: auth.sessionId }; | |
| 113 | const grant = { repo, machine, user, sessionId: auth.sessionId, tty: await appTty(app, repo) }; | |
| 107 | 114 | if (!roomFor(app, grant)) return { ok: false, refusal: "busy" }; |
| 115 | // The first connection clones; async, so no other request waits on it. | |
| 116 | await ensureCheckout( | |
| 117 | repoDir(app.reposRoot, repo.ownerName, repo.name), | |
outdated
Clone runs on every handshake that finds no checkout, after the room check but with no limit on how many run at once across people. Bound the concurrency, or clone inside the session start where the existing caps apply. |
||
| 118 | workspace(app, repo, machine, user.id), | |
| 119 | ); | |
| 108 | 120 | return { ok: true, grant }; |
| 109 | 121 | } |
| 110 | 122 | |
| 123 | /** The `tty` command forge.json declares at the default branch's tip, or null. */ | |
| 124 | export async function appTty(app: App, repo: Repo): Promise<string | null> { | |
| 125 | const dir = repoDir(app.reposRoot, repo.ownerName, repo.name); | |
| 126 | const tip = await resolveOid(dir, `refs/heads/${repo.defaultBranch}`); | |
| 127 | if (tip === undefined) return null; | |
| 128 | const parsed = parseManifest(await readFileAt(dir, tip, "forge.json"), undefined); | |
| 129 | return parsed.ok ? parsed.value.tty : null; | |
| 130 | } | |
| 131 | ||
| 111 | 132 | // ---- frames ---- |
| 112 | 133 | |
| 113 | 134 | export const RESIZE = { cols: { min: 1, max: 500 }, rows: { min: 1, max: 300 } } as const; |
| @@ -266,6 +287,8 @@ export interface ShellOptions { | ||
| 266 | 287 | readonly contain: "cell" | "uncontained" | "refuse"; |
| 267 | 288 | /** The command the pty runs: constant, never built from a request. */ |
| 268 | 289 | readonly command: string; |
| 290 | /** Whether leaving the command keeps the session (tmux holds it); said in the greeting. Defaults to no. */ | |
| 291 | readonly detaches?: boolean; | |
| 269 | 292 | /** How often a session asks again whether its owner may still write; defaults to `CHECK_MS`. */ |
| 270 | 293 | readonly checkMs?: number; |
| 271 | 294 | /** Handshakes one person may make in a minute; defaults to `HANDSHAKES_PER_MINUTE`. */ |
| @@ -283,10 +306,27 @@ export const MAX_BUFFERED_BYTES = 1024 * 1024; | ||
| 283 | 306 | export const CHECK_MS = 5_000; |
| 284 | 307 | |
| 285 | 308 | /** tmux where the machine has it, so a person's windows survive a closed tab; else a login shell. */ |
| 286 | export function defaultCommand(): string { | |
| 287 | return Bun.which("tmux") !== null | |
| 288 | ? "exec tmux -L forge new-session -A -s shell" | |
| 289 | : "exec /bin/sh -i"; | |
| 309 | export function defaultShell(): ShellPlan { | |
| 310 | return shellPlan(Bun.which("tmux") !== null, Bun.which("bash") !== null); | |
| 311 | } | |
| 312 | ||
outdated
defaultCommand is no longer used by anything but a stale import in server.ts. Remove it and the import. |
||
| 313 | /** | |
| 314 | * The environment a machine shell starts with: the forge's own bun first on | |
| 315 | * PATH (the cell binds `/` read-only and reveals bun's directory even under a | |
| 316 | * hidden home, see `cellMounts`), then the forge's PATH, where git is. | |
| 317 | */ | |
| 318 | export function shellEnv(grant: ShellGrant, dir: string): Record<string, string> { | |
| 319 | return { | |
| 320 | PATH: `${dirname(process.execPath)}:${process.env["PATH"] ?? "/usr/bin:/bin"}`, | |
| 321 | FORGE_REPO: `${grant.repo.ownerName}/${grant.repo.name}`, | |
| 322 | FORGE_CHECKOUT: join(dir, CHECKOUT_DIR), | |
| 323 | HOME: join(dir, "home"), | |
| 324 | TERM: "xterm-256color", | |
| 325 | COLORTERM: "truecolor", | |
| 326 | LANG: "C.UTF-8", | |
| 327 | FORGE_MACHINE: grant.machine.name, | |
| 328 | FORGE_MACHINE_TOOLS: grant.machine.tools.join(" "), | |
| 329 | }; | |
| 290 | 330 | } |
| 291 | 331 | |
| 292 | 332 | const sessions = new WeakMap<App, Map<string, Session>>(); |
| @@ -420,6 +460,13 @@ function start(app: App, grant: ShellGrant, opts: ShellOptions, cols: number, ro | ||
| 420 | 460 | let live: Session | undefined; |
| 421 | 461 | try { |
| 422 | 462 | mkdirSync(join(dir, "home"), { recursive: true }); |
| 463 | prepareHome(join(dir, "home"), { | |
| 464 | repo: `${grant.repo.ownerName}/${grant.repo.name}`, | |
| 465 | machine: grant.machine.name, | |
| 466 | detaches: opts.detaches === true, | |
| 467 | checkout: existsSync(join(dir, CHECKOUT_DIR, ".git")), | |
| 468 | tty: grant.tty ?? null, | |
| 469 | }); | |
| 423 | 470 | row = recordStart(app.db, grant.repo.id, grant.machine.name, grant.user.id); |
| 424 | 471 | terminal = new Bun.Terminal({ |
| 425 | 472 | cols, |
| @@ -444,15 +491,7 @@ function start(app: App, grant: ShellGrant, opts: ShellOptions, cols: number, ro | ||
| 444 | 491 | ownAppUrls(ownApp(app, grant.repo)), |
| 445 | 492 | ), |
| 446 | 493 | ); |
| 447 | const env: Record<string, string> = { | |
| 448 | PATH: process.env["PATH"] ?? "/usr/bin:/bin", | |
| 449 | HOME: join(dir, "home"), | |
| 450 | TERM: "xterm-256color", | |
| 451 | COLORTERM: "truecolor", | |
| 452 | LANG: "C.UTF-8", | |
| 453 | FORGE_MACHINE: grant.machine.name, | |
| 454 | FORGE_MACHINE_TOOLS: grant.machine.tools.join(" "), | |
| 455 | }; | |
| 494 | const env = shellEnv(grant, dir); | |
| 456 | 495 | const program = startTerminal(opts.command, { terminal, env, cwd: dir, cell }); |
| 457 | 496 | const term = terminal; |
| 458 | 497 | const session: Session = { |
test/machine-rc.test.ts added +232
| @@ -0,0 +1,232 @@ | ||
| 1 | /** | |
| 2 | * A machine shell's first screen (#10547): the rc holds the repository-and-branch prompt, the greeting says where you are and how to leave, | |
| 3 | * history lives under the data directory and is there in the next session. | |
| 4 | */ | |
| 5 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 6 | import { existsSync, mkdirSync, mkdtempSync, readFileSync } from "node:fs"; | |
| 7 | import { tmpdir } from "node:os"; | |
| 8 | import { dirname, join } from "node:path"; | |
| 9 | import { createApp, type App } from "../src/app.ts"; | |
| 10 | import { createRepo, type Repo } from "../src/core/repos.ts"; | |
| 11 | import { createUser } from "../src/core/users.ts"; | |
| 12 | import { commitFiles } from "../src/git/write.ts"; | |
| 13 | import { repoDir } from "../src/git/repos.ts"; | |
| 14 | import { BUILT_IN } from "../src/crew/machines.ts"; | |
| 15 | import { RC, RC_FILE, CHECKOUT_DIR, MAX_CLONES, ensureCheckout, greeting, historyPath, prepareHome, shellPlan } from "../src/machines/rc.ts"; | |
| 16 | import { appTty, shellEnv, workspace } from "../src/machines/shell.ts"; | |
| 17 | import { cellMounts, keptPaths } from "../src/apps/contain.ts"; | |
| 18 | ||
| 19 | const facts = { repo: "alice/lab", machine: "standard", detaches: true, checkout: true, tty: "bun run tty.ts" }; | |
| 20 | ||
| 21 | let app: App; | |
| 22 | let repo: Repo; | |
| 23 | let userId: string; | |
| 24 | let bare: string; | |
| 25 | ||
| 26 | beforeAll(async () => { | |
| 27 | app = createApp({ dataDir: mkdtempSync(join(tmpdir(), "rc-data-")), port: 0 }); | |
| 28 | const made = await createUser(app.db, { username: "alice", email: "[email protected]", password: "correct horse battery", origin: "seed", isAdmin: false, trusted: true }); | |
| 29 | if (!made.ok) throw new Error(String(made.error)); | |
| 30 | userId = made.value.id; | |
| 31 | const r = await createRepo(app.db, app.reposRoot, { ownerId: userId, name: "lab", visibility: "public" }); | |
| 32 | if (!r.ok) throw new Error(String(r.error)); | |
| 33 | repo = r.value; | |
| 34 | bare = repoDir(app.reposRoot, repo.ownerName, repo.name); | |
| 35 | const c = await commitFiles(bare, { | |
| 36 | branch: repo.defaultBranch, | |
| 37 | message: "seed", | |
| 38 | files: [ | |
| 39 | { path: "forge.json", content: JSON.stringify({ app: { run: "bun run index.ts", tty: "bun run tty.ts" } }) }, | |
| 40 | { path: "README.md", content: "hi\n" }, | |
| 41 | ], | |
| 42 | author: { name: "T", email: "[email protected]" }, | |
| 43 | }); | |
| 44 | if (!c.ok) throw new Error(JSON.stringify(c.error)); | |
| 45 | }); | |
| 46 | ||
| 47 | afterAll(() => app.close()); | |
| 48 | ||
| 49 | const standard = () => BUILT_IN.find((m) => m.name === "standard")!; | |
| 50 | ||
| 51 | test("the rc holds the repository and branch prompt, a long history and ls aliases", () => { | |
| 52 | expect(RC).toContain("$FORGE_REPO"); | |
| 53 | expect(RC).toContain("HISTSIZE=100000"); | |
| 54 | expect(RC).toContain("alias ll="); | |
| 55 | expect(RC).not.toContain("eval"); | |
| 56 | }); | |
| 57 | ||
| 58 | test("the real prompt shows repo:branch in a git checkout", async () => { | |
| 59 | const dir = workspace(app, repo, standard(), userId); | |
| 60 | const home = join(dir, "home"); | |
| 61 | expect(await ensureCheckout(bare, dir)).toBe(true); | |
| 62 | prepareHome(home, facts); | |
| 63 | const env = { PATH: process.env["PATH"] ?? "/usr/bin:/bin", HOME: home, FORGE_REPO: "alice/lab", FORGE_CHECKOUT: join(dir, CHECKOUT_DIR), TERM: "dumb" }; | |
| 64 | // PS1 is shown only by an interactive shell on a terminal; expand it the way bash does. | |
| 65 | const run = Bun.spawnSync(["bash", "--rcfile", join(home, RC_FILE), "-i"], { | |
| 66 | env, | |
| 67 | cwd: home, | |
| 68 | stdin: new TextEncoder().encode('echo "PROMPT=${PS1@P}"\nexit\n'), | |
| 69 | }); | |
| 70 | const out = run.stdout.toString() + run.stderr.toString(); | |
| 71 | expect(out.replace(/[\x01\x02]|\x1b\[[0-9;]*m/g, "")).toContain(`PROMPT=alice/lab:${repo.defaultBranch} `); | |
| 72 | }); | |
| 73 | ||
| 74 | test("the greeting names the repository, machine, how to leave, and the app's tty", () => { | |
| 75 | const text = greeting(facts); | |
| 76 | expect(text).toContain('machine "standard" of alice/lab'); | |
| 77 | expect(text).toContain("its checkout"); | |
| 78 | expect(text).toContain("detach"); | |
| 79 | expect(text).toContain("bun run tty.ts"); | |
| 80 | expect(greeting({ ...facts, detaches: false })).not.toContain("detach"); | |
| 81 | expect(greeting({ ...facts, tty: null })).not.toContain("terminal command"); | |
| 82 | expect(greeting({ ...facts, tty: "x\x1b[2Jy" })).not.toContain("\x1b"); | |
| 83 | const none = greeting({ ...facts, checkout: false }); | |
| 84 | expect(none).not.toContain("You are in its checkout"); | |
| 85 | expect(none).toContain("home directory"); | |
| 86 | }); | |
| 87 | ||
| 88 | test("the app's tty is read from forge.json at the default tip", async () => { | |
| 89 | expect(await appTty(app, repo)).toBe("bun run tty.ts"); | |
| 90 | }); | |
| 91 | ||
| 92 | test("a checkout is cloned once and reused; a failed clone leaves nothing and says so", async () => { | |
| 93 | const dir = mkdtempSync(join(tmpdir(), "rc-co-")); | |
| 94 | expect(await ensureCheckout(bare, dir)).toBe(true); | |
| 95 | const target = join(dir, CHECKOUT_DIR); | |
| 96 | expect(readFileSync(join(target, "README.md"), "utf8")).toBe("hi\n"); | |
| 97 | Bun.spawnSync(["touch", join(target, "marker")]); | |
| 98 | expect(await ensureCheckout(bare, dir)).toBe(true); | |
| 99 | expect(existsSync(join(target, "marker"))).toBe(true); | |
| 100 | const bad = mkdtempSync(join(tmpdir(), "rc-bad-")); | |
| 101 | expect(await ensureCheckout(join(bad, "missing.git"), bad)).toBe(false); | |
| 102 | expect(existsSync(join(bad, CHECKOUT_DIR))).toBe(false); | |
| 103 | }); | |
| 104 | ||
| 105 | test("the clone does not block the event loop", async () => { | |
| 106 | const dir = mkdtempSync(join(tmpdir(), "rc-loop-")); | |
| 107 | let ticks = 0; | |
| 108 | const timer = setInterval(() => ticks++, 1); | |
| 109 | await ensureCheckout(bare, dir); | |
| 110 | clearInterval(timer); | |
| 111 | expect(ticks).toBeGreaterThan(0); | |
| 112 | }); | |
| 113 | ||
| 114 | test("the rc is the same constant whatever the facts", () => { | |
| 115 | const home = mkdtempSync(join(tmpdir(), "rc-")); | |
| 116 | prepareHome(home, { ...facts, repo: "evil/$(touch pwned)" }); | |
| 117 | expect(readFileSync(join(home, RC_FILE), "utf8")).toBe(RC); | |
| 118 | }); | |
| 119 | ||
| 120 | test("history is under the data directory and survives a second session", () => { | |
| 121 | const dir = workspace(app, repo, standard(), userId); | |
| 122 | const home = join(dir, "home"); | |
| 123 | mkdirSync(home, { recursive: true }); | |
| 124 | prepareHome(home, facts); | |
| 125 | expect(historyPath(home).startsWith(app.config.dataDir)).toBe(true); | |
| 126 | const env = { PATH: process.env["PATH"] ?? "/usr/bin:/bin", HOME: home, FORGE_REPO: "alice/lab" }; | |
| 127 | const run = (input: string) => | |
| 128 | Bun.spawnSync(["bash", "--rcfile", join(home, RC_FILE), "-i"], { env, stdin: new TextEncoder().encode(input), cwd: home }); | |
| 129 | run("echo remembered-command\nexit\n"); | |
| 130 | expect(readFileSync(historyPath(home), "utf8")).toContain("remembered-command"); | |
| 131 | const second = run("history\nexit\n"); | |
| 132 | expect(second.stdout.toString() + second.stderr.toString()).toContain("remembered-command"); | |
| 133 | }); | |
| 134 | ||
| 135 | test("the shell plan runs bash on the rc, in tmux when there is one, and says whether it detaches", () => { | |
| 136 | expect(shellPlan(true, true).command).toContain("tmux"); | |
| 137 | expect(shellPlan(true, true).command).toContain(RC_FILE); | |
| 138 | expect(shellPlan(true, true).detaches).toBe(true); | |
| 139 | expect(shellPlan(false, true)).toEqual({ command: `exec bash --rcfile "$HOME/${RC_FILE}" -i`, detaches: false }); | |
| 140 | expect(shellPlan(false, false).detaches).toBe(false); | |
| 141 | }); | |
| 142 | ||
| 143 | test("the shell's env puts bun first on PATH, and bun and git resolve with it", () => { | |
| 144 | const dir = workspace(app, repo, standard(), userId); | |
| 145 | const grant = { repo, machine: standard(), user: { id: userId } as never, sessionId: undefined }; | |
| 146 | const env = shellEnv(grant, dir); | |
| 147 | expect(env["PATH"]!.split(":")[0]).toBe(dirname(process.execPath)); | |
| 148 | expect(env["FORGE_REPO"]).toBe("alice/lab"); | |
| 149 | expect(env["FORGE_CHECKOUT"]).toBe(join(dir, CHECKOUT_DIR)); | |
| 150 | expect(env["HOME"]).toBe(join(dir, "home")); | |
| 151 | const run = Bun.spawnSync(["sh", "-c", "command -v bun && command -v git"], { env }); | |
| 152 | const found = run.stdout.toString().trim().split("\n"); | |
| 153 | expect(found[0]).toBe(join(dirname(process.execPath), "bun")); | |
| 154 | expect(found[1]).toMatch(/\/git$/); | |
| 155 | }); | |
| 156 | ||
| 157 | test("a cell keeps bun's and git's directories visible", () => { | |
| 158 | // The cell binds / read-only; a hidden home that holds bun is revealed again. | |
| 159 | const home = dirname(dirname(process.execPath)); | |
| 160 | expect(cellMounts({ hide: [home] }, []).reveal).toContain(dirname(process.execPath)); | |
| 161 | // Under the allow-list (Kubernetes), only kept mounts are seen: bun's and git's are kept. | |
| 162 | const kept = keptPaths(); | |
| 163 | expect(kept).toContain(dirname(process.execPath)); | |
| 164 | expect(kept).toContain(dirname(Bun.which("git")!)); | |
| 165 | }); | |
| 166 | ||
| 167 | test("the plans quote a hostile repository and checkout path: sh runs them as text", () => { | |
| 168 | const dir = mkdtempSync(join(tmpdir(), "rc-q-")); | |
| 169 | const home = join(dir, "home"); | |
| 170 | const evil = join(dir, "co'; touch PWNED; '$(touch PWNED2)"); | |
| 171 | mkdirSync(evil, { recursive: true }); | |
| 172 | prepareHome(home, facts); | |
| 173 | for (const plan of [shellPlan(false, false), shellPlan(false, true)]) { | |
| 174 | const run = Bun.spawnSync(["sh", "-c", plan.command], { | |
| 175 | env: { PATH: process.env["PATH"] ?? "/usr/bin:/bin", HOME: home, FORGE_REPO: "x/$(touch PWNED3)`touch PWNED4`", FORGE_CHECKOUT: evil, TERM: "dumb" }, | |
| 176 | cwd: dir, | |
| 177 | stdin: new TextEncoder().encode("pwd\nexit\n"), | |
| 178 | }); | |
| 179 | expect(run.stdout.toString() + run.stderr.toString()).toContain(evil); | |
| 180 | } | |
| 181 | for (const f of ["PWNED", "PWNED2", "PWNED3", "PWNED4"]) { | |
| 182 | expect(existsSync(join(dir, f))).toBe(false); | |
| 183 | expect(existsSync(join(evil, f))).toBe(false); | |
| 184 | } | |
| 185 | // tmux's plan wraps the same inner command in one single-quoted word. | |
| 186 | const tmux = shellPlan(true, false).command; | |
| 187 | const inner = shellPlan(false, false).command.slice("exec ".length); | |
| 188 | expect(tmux).toBe(`exec tmux -L forge new-session -A -s shell '${inner.replace(/'/g, "'\\''")}'`); | |
| 189 | const word = Bun.spawnSync(["sh", "-c", `printf %s ${tmux.slice(tmux.indexOf("'"))}`]); | |
| 190 | expect(word.stdout.toString()).toBe(inner); | |
| 191 | }); | |
| 192 | ||
| 193 | test("two handshakes at once share one clone; neither removes the other's checkout", async () => { | |
| 194 | const dir = mkdtempSync(join(tmpdir(), "rc-race-")); | |
| 195 | const a = ensureCheckout(bare, dir); | |
| 196 | const b = ensureCheckout(bare, dir); | |
| 197 | expect(b).toBe(a); | |
| 198 | expect(await Promise.all([a, b])).toEqual([true, true]); | |
| 199 | expect(readFileSync(join(dir, CHECKOUT_DIR, "README.md"), "utf8")).toBe("hi\n"); | |
| 200 | expect(existsSync(join(dir, `${CHECKOUT_DIR}.partial`))).toBe(false); | |
| 201 | }); | |
| 202 | ||
| 203 | test("a clone past its deadline is killed and leaves nothing; the shell starts in home", async () => { | |
| 204 | const dir = mkdtempSync(join(tmpdir(), "rc-slow-")); | |
| 205 | expect(await ensureCheckout(bare, dir, 1)).toBe(false); | |
| 206 | expect(existsSync(join(dir, CHECKOUT_DIR))).toBe(false); | |
| 207 | expect(existsSync(join(dir, `${CHECKOUT_DIR}.partial`))).toBe(false); | |
| 208 | expect(await ensureCheckout(bare, dir)).toBe(true); | |
| 209 | }); | |
| 210 | ||
| 211 | test("past the forge-wide cap a clone is not started", async () => { | |
| 212 | const dirs = Array.from({ length: MAX_CLONES + 1 }, () => mkdtempSync(join(tmpdir(), "rc-cap-"))); | |
| 213 | const results = await Promise.all(dirs.map((d) => ensureCheckout(bare, d))); | |
| 214 | expect(results.slice(0, MAX_CLONES)).toEqual(Array(MAX_CLONES).fill(true)); | |
| 215 | expect(results[MAX_CLONES]).toBe(false); | |
| 216 | expect(existsSync(join(dirs[MAX_CLONES]!, CHECKOUT_DIR))).toBe(false); | |
| 217 | }); | |
| 218 | ||
| 219 | test("without bash, sh gets the greeting, the checkout and a repository prompt", () => { | |
| 220 | const dir = mkdtempSync(join(tmpdir(), "rc-sh-")); | |
| 221 | const home = join(dir, "home"); | |
| 222 | mkdirSync(join(dir, CHECKOUT_DIR), { recursive: true }); | |
| 223 | prepareHome(home, facts); | |
| 224 | const run = Bun.spawnSync(["sh", "-c", shellPlan(false, false).command], { | |
| 225 | env: { PATH: process.env["PATH"] ?? "/usr/bin:/bin", HOME: home, FORGE_REPO: "alice/lab", FORGE_CHECKOUT: join(dir, CHECKOUT_DIR) }, | |
| 226 | stdin: new TextEncoder().encode("pwd\nexit\n"), | |
| 227 | }); | |
| 228 | const out = run.stdout.toString() + run.stderr.toString(); | |
| 229 | expect(out).toContain('machine "standard" of alice/lab'); | |
| 230 | expect(out).toContain(join(dir, CHECKOUT_DIR)); | |
| 231 | expect(out).toContain("alice/lab $ "); | |
| 232 | }); |
Concurrent calls for the same dir race: the second does rmSync(target) on line 101 while the first is cloning. Share one in-flight promise per target, and kill git after a deadline (Bun.spawn accepts timeout/killSignal). Without a bound, one slow or huge repository holds the handshake and the disk.