Public: anyone can read this; the crew can change it.
Your dotfiles from a repository you name are in every shell you open #10721
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/10548-your-dotfiles-from-a-repository-you-name → main 4 commits fork 4 threads · 4 unresolved
docs/features/dotfiles.md added +13
| @@ -0,0 +1,13 @@ | ||
| 1 | # Your dotfiles in every shell | |
| 2 | ||
| 3 | Settings → "Dotfiles" takes one line: `owner/name`, a repository on this forge that you can read, or empty for none. Anything else, or a repository you cannot read, is refused. | |
| 4 | ||
| 5 | A new machine shell copies that repository's top-level dotfiles (default branch) into the shell's home after the forge's own rc is written, so the forge's defaults come first and yours win. The rc sources `.bashrc` itself; other files (`.gitconfig`, `.tmux.conf`, `.vimrc`) are read by their own tools. Nothing else is run. | |
| 6 | ||
| 7 | Limits: regular files only (no symlinks or folders), names like `.word`, never `.git` or `.forge_*`, 64 KB each, 32 files. Files a previous session copied are removed first, so changing or clearing the setting leaves none behind. | |
| 8 | ||
| 9 | If the repository is missing or you lost access, the shell opens anyway and its greeting names the repository once. | |
| 10 | ||
| 11 | Set it on Settings › Shells, or in the terminal with the palette entry "Your dotfiles" (`e` to name, `x` to clear); both go through `setDotfilesRepo`. Files are written by removing the old entry first, so a symlink planted in the home is replaced, never followed. | |
| 12 | ||
| 13 | Code: `src/machines/dotfiles.ts`, `src/tui/screens/dotfiles.ts`; tests in `test/machine-rc.test.ts`, `test/dotfiles-route.test.ts`, `test/tui-dotfiles.test.ts`. |
src/http/settings_routes.ts +2
| @@ -99,6 +99,7 @@ import { | ||
| 99 | 99 | edgeUnlink, |
| 100 | 100 | parentUnlink, |
| 101 | 101 | accountUpdate, |
| 102 | dotfilesUpdate, | |
| 102 | 103 | keyCreate, |
| 103 | 104 | keyDelete, |
| 104 | 105 | keysPage, |
| @@ -140,6 +141,7 @@ export function settingsRoutes(router: Router<Handler>, knownPatterns: () => rea | ||
| 140 | 141 | router.add("GET", "/settings", accountPage); |
| 141 | 142 | router.add("POST", "/settings", accountUpdate); |
| 142 | 143 | router.add("POST", "/settings/notify", notifyUpdate); |
| 144 | router.add("POST", "/settings/dotfiles", dotfilesUpdate); | |
| 143 | 145 | router.add("POST", "/settings/edge/link", edgeLink); |
| 144 | 146 | router.add("POST", "/settings/edge/unlink", edgeUnlink); |
| 145 | 147 | router.add("POST", "/settings/parent/link", parentLinkStart); |
src/machines/dotfiles.ts added +146
| @@ -0,0 +1,146 @@ | ||
| 1 | /** | |
| 2 | * A person's dotfiles in every shell they open (#10548): they name one | |
| 3 | * repository on this forge that they can read, and a new shell copies its | |
| 4 | * top-level dotfiles into the cell's home after the forge's own rc is | |
| 5 | * written, so the forge's defaults come first and theirs win. | |
| 6 | * | |
| 7 | * Nothing is run here. Files are copied as plain data: only regular files | |
| 8 | * (never symlinks or directories), only names that are a dot and a short | |
| 9 | * safe word, never `.git` or the forge's own `.forge_*`, each under a size | |
| 10 | * ceiling and at most `MAX_FILES` of them. The forge's rc sources `.bashrc` | |
| 11 | * itself, which is the one dotfile the shell evaluates; the others (a | |
| 12 | * `.gitconfig`, a `.tmux.conf`) are read by the tool they belong to. | |
| 13 | * | |
| 14 | * A repository that is missing, or that the person cannot read, is not an | |
| 15 | * error for the shell: it opens, and its greeting names the repository once. | |
| 16 | * The repository is read as the person at open time, so a repository they | |
| 17 | * lose access to stops being copied; files an earlier session copied are | |
| 18 | * removed first (a manifest in the home lists them), so a changed or emptied | |
| 19 | * setting does not leave the old ones behind. | |
| 20 | */ | |
| 21 | import { lstatSync, readFileSync, rmSync, writeFileSync } from "node:fs"; | |
| 22 | import { join } from "node:path"; | |
| 23 | import type { Database } from "bun:sqlite"; | |
| 24 | import type { App } from "../app.ts"; | |
| 25 | import { canRead, getRepo } from "../core/repos.ts"; | |
| 26 | import { getUserSetting, setUserSetting } from "../core/settings.ts"; | |
| 27 | import type { User } from "../core/users.ts"; | |
| 28 | import { blobForRender, listTree } from "../git/read.ts"; | |
| 29 | import { repoDir } from "../git/repos.ts"; | |
| 30 | import { isSafeName } from "../git/guard.ts"; | |
| 31 | import { resolveOid } from "../git/merge.ts"; | |
| 32 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 33 | ||
| 34 | const KEY = "dotfiles_repo"; | |
| 35 | export const MAX_FILES = 32; | |
| 36 | export const MAX_FILE_BYTES = 64 * 1024; | |
| 37 | export const MANIFEST_FILE = ".forge_dotfiles"; | |
| 38 | const DOTFILE = /^\.[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/; | |
| 39 | ||
| 40 | export type DotfilesError = "invalid" | "not_found"; | |
| 41 | ||
| 42 | export const DOTFILES_ERRORS: Record<DotfilesError, string> = { | |
| 43 | invalid: "Name a repository as owner/name, or leave this empty for none.", | |
| 44 | not_found: "No repository by that name that you can read.", | |
| 45 | }; | |
| 46 | ||
| 47 | /** `owner/name` from text, "" for none; anything else is refused. */ | |
| 48 | export function parseDotfilesRepo(text: string): Result<string, "invalid"> { | |
| 49 | const t = text.trim(); | |
| 50 | if (t === "") return ok(""); | |
| 51 | if (t.length > 120) return err("invalid"); | |
| 52 | const parts = t.split("/"); | |
| 53 | if (parts.length !== 2) return err("invalid"); | |
| 54 | const [owner, name] = parts as [string, string]; | |
| 55 | return isSafeName(owner) && isSafeName(name) ? ok(`${owner}/${name}`) : err("invalid"); | |
| 56 | } | |
| 57 | ||
| 58 | /** The repository a person named, or undefined for none. */ | |
| 59 | export function dotfilesRepoOf(db: Database, userId: string): string | undefined { | |
| 60 | const v = getUserSetting(db, userId, KEY); | |
| 61 | return v === undefined || v === "" ? undefined : v; | |
| 62 | } | |
| 63 | ||
| 64 | /** Save the person's choice; a repository they cannot read is refused here too. */ | |
| 65 | export function setDotfilesRepo(db: Database, user: User, text: string): Result<"set" | "cleared", DotfilesError> { | |
| 66 | const parsed = parseDotfilesRepo(text); | |
| 67 | if (!parsed.ok) return err("invalid"); | |
| 68 | if (parsed.value === "") { | |
| 69 | setUserSetting(db, user.id, KEY, ""); | |
| 70 | return ok("cleared"); | |
| 71 | } | |
| 72 | const [owner, name] = parsed.value.split("/") as [string, string]; | |
| 73 | const repo = getRepo(db, owner, name); | |
| 74 | if (repo === undefined || !canRead(db, repo, user)) return err("not_found"); | |
| 75 | setUserSetting(db, user.id, KEY, parsed.value); | |
| 76 | return ok("set"); | |
| 77 | } | |
| 78 | ||
| 79 | export interface DotFile { | |
| 80 | readonly name: string; | |
| 81 | readonly body: Uint8Array; | |
| 82 | } | |
| 83 | ||
| 84 | export interface DotfileSet { | |
| 85 | readonly files: readonly DotFile[]; | |
| 86 | /** The repository that could not be read, named for the greeting; null when nothing is wrong. */ | |
| 87 | readonly unreadable: string | null; | |
| 88 | } | |
| 89 | ||
| 90 | export const NO_DOTFILES: DotfileSet = { files: [], unreadable: null }; | |
| 91 | ||
| 92 | /** Whether a top-level name is one the shell may be given. */ | |
| 93 | export function isCopyable(name: string): boolean { | |
| 94 | return DOTFILE.test(name) && name !== ".git" && !name.startsWith(".forge_"); | |
| 95 | } | |
| 96 | ||
| 97 | /** Read the named repository's dotfiles as `user`; never throws. */ | |
| 98 | export async function loadDotfiles(app: App, user: User): Promise<DotfileSet> { | |
| 99 | const named = dotfilesRepoOf(app.db, user.id); | |
| 100 | if (named === undefined) return NO_DOTFILES; | |
| 101 | const refused: DotfileSet = { files: [], unreadable: named }; | |
| 102 | try { | |
| 103 | const parsed = parseDotfilesRepo(named); | |
| 104 | if (!parsed.ok || parsed.value === "") return refused; | |
| 105 | const [owner, name] = parsed.value.split("/") as [string, string]; | |
| 106 | const repo = getRepo(app.db, owner, name); | |
| 107 | if (repo === undefined || !canRead(app.db, repo, user)) return refused; | |
| 108 | const dir = repoDir(app.reposRoot, repo.ownerName, repo.name); | |
| 109 | const tip = await resolveOid(dir, `refs/heads/${repo.defaultBranch}`); | |
| 110 | if (tip === undefined) return { files: [], unreadable: null }; | |
| 111 | const tree = await listTree(dir, tip, ""); | |
| 112 | if (!tree.ok) return refused; | |
| 113 | const files: DotFile[] = []; | |
| 114 | for (const e of tree.value) { | |
| 115 | if (files.length >= MAX_FILES) break; | |
| 116 | if (e.type !== "blob" || (e.mode !== "100644" && e.mode !== "100755")) continue; | |
| 117 | if (!isCopyable(e.name) || e.size > MAX_FILE_BYTES) continue; | |
| 118 | const blob = await blobForRender(dir, tip, e.name, MAX_FILE_BYTES); | |
| 119 | if (blob.kind === "text" || blob.kind === "binary") files.push({ name: e.name, body: blob.body }); | |
| 120 | } | |
| 121 | return { files, unreadable: null }; | |
| 122 | } catch { | |
| 123 | return refused; | |
| 124 | } | |
| 125 | } | |
| 126 | ||
| 127 | /** | |
| 128 | * Write a file the person's home may already hold something at: the old entry | |
| 129 | * is removed first (a symlink goes, never its target) and the new file is | |
| 130 | * created exclusively, so a link planted between the two is refused too. | |
| 131 | */ | |
| 132 | export function writeFresh(path: string, data: string | Uint8Array, mode = 0o644): void { | |
| 133 | rmSync(path, { force: true }); | |
| 134 | writeFileSync(path, data, { mode, flag: "wx" }); | |
outdated
High. writeFileSync follows a symlink the person planted in their home (e.g. ~/.bashrc -> a forge-writable file), so the forge overwrites that target on the next shell open. rmSync(dest, {force:true}) first (removes the link, not its target), then write; add a test with a planted symlink. |
||
| 135 | } | |
| 136 | ||
| 137 | /** Copy the set into `home`, replacing what the last session copied. */ | |
| 138 | export function applyDotfiles(home: string, set: DotfileSet): void { | |
| 139 | const manifest = join(home, MANIFEST_FILE); | |
| 140 | if (lstatSync(manifest, { throwIfNoEntry: false })?.isFile()) { | |
| 141 | for (const name of readFileSync(manifest, "utf8").split("\n")) | |
| 142 | if (isCopyable(name)) rmSync(join(home, name), { force: true }); | |
| 143 | } | |
| 144 | for (const f of set.files) writeFresh(join(home, f.name), f.body); | |
| 145 | writeFresh(manifest, set.files.map((f) => f.name).join("\n")); | |
| 146 | } |
src/machines/rc.ts +17 −6
| @@ -14,9 +14,10 @@ | ||
| 14 | 14 | * repository and machine, and written after every command so a tab closed |
| 15 | 15 | * mid-session loses nothing. |
| 16 | 16 | */ |
| 17 | import { mkdirSync, writeFileSync, existsSync, renameSync, rmSync } from "node:fs"; | |
| 17 | import { mkdirSync, lstatSync, existsSync, renameSync, rmSync } from "node:fs"; | |
| 18 | 18 | import { join } from "node:path"; |
| 19 | 19 | import { gitEnv } from "../git/env.ts"; |
| 20 | import { applyDotfiles, writeFresh, NO_DOTFILES, type DotfileSet } from "./dotfiles.ts"; | |
| 20 | 21 | |
| 21 | 22 | export const RC_FILE = ".forge_rc"; |
| 22 | 23 | export const GREETING_FILE = ".forge_greeting"; |
| @@ -24,7 +25,11 @@ export const HISTORY_FILE = ".forge_history"; | ||
| 24 | 25 | /** The checkout's directory under the person's workspace. */ |
| 25 | 26 | export const CHECKOUT_DIR = "checkout"; |
| 26 | 27 | |
| 27 | /** The rc file: constant. Reads FORGE_REPO, FORGE_MACHINE and the greeting file. */ | |
| 28 | /** | |
| 29 | * The rc file: constant. Reads FORGE_REPO, FORGE_MACHINE and the greeting file. | |
| 30 | * It sources the person's own `.bashrc` (from their dotfiles repository, | |
| 31 | * #10548) after the forge's defaults, so theirs win. | |
| 32 | */ | |
| 28 | 33 | export const RC = `# The forge's machine shell rc. Written by the forge; edits are replaced. |
| 29 | 34 | case $- in *i*) ;; *) return ;; esac |
| 30 | 35 | export HISTFILE="$HOME/${HISTORY_FILE}" |
| @@ -41,6 +46,7 @@ forge_branch() { | ||
| 41 | 46 | git symbolic-ref --short -q HEAD 2>/dev/null || git rev-parse --short HEAD 2>/dev/null |
| 42 | 47 | } |
| 43 | 48 | PS1='\\[\\e[1;36m\\]$FORGE_REPO\\[\\e[0m\\]:\\[\\e[1;33m\\]$(forge_branch)\\[\\e[0m\\] \\w \\$ ' |
| 49 | if [ -f "$HOME/.bashrc" ]; then . "$HOME/.bashrc"; fi | |
| 44 | 50 | if [ -d "$FORGE_CHECKOUT" ]; then cd "$FORGE_CHECKOUT"; fi |
| 45 | 51 | if [ -f "$HOME/${GREETING_FILE}" ]; then cat "$HOME/${GREETING_FILE}"; fi |
| 46 | 52 | `; |
| @@ -59,6 +65,8 @@ export interface GreetingFacts { | ||
| 59 | 65 | readonly checkout: boolean; |
| 60 | 66 | /** The app's own `tty` command from forge.json, when it has one. */ |
| 61 | 67 | readonly tty: string | null; |
| 68 | /** A dotfiles repository the person named that could not be read; the greeting says so once. */ | |
| 69 | readonly dotfilesUnreadable?: string | null; | |
| 62 | 70 | } |
| 63 | 71 | |
| 64 | 72 | /** The greeting, in plain words: where you are and how to leave. */ |
| @@ -72,6 +80,8 @@ export function greeting(facts: GreetingFacts): string { | ||
| 72 | 80 | ]; |
| 73 | 81 | if (facts.tty !== null && plain(facts.tty) !== "") |
| 74 | 82 | lines.push(`The app's own terminal command: ${plain(facts.tty)}`); |
| 83 | if (facts.dotfilesUnreadable != null) | |
| 84 | lines.push(`Your dotfiles repository ${plain(facts.dotfilesUnreadable)} is missing or you cannot read it, so none were copied.`); | |
| 75 | 85 | return `${lines.join("\n")}\n\n`; |
| 76 | 86 | } |
| 77 | 87 | |
| @@ -81,11 +91,12 @@ export function historyPath(home: string): string { | ||
| 81 | 91 | } |
| 82 | 92 | |
| 83 | 93 | /** Write the rc and greeting into `home`, and make the history file exist. */ |
| 84 | export function prepareHome(home: string, facts: GreetingFacts): void { | |
| 94 | export function prepareHome(home: string, facts: GreetingFacts, dotfiles: DotfileSet = NO_DOTFILES): void { | |
| 85 | 95 | 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), ""); | |
| 96 | writeFresh(join(home, RC_FILE), RC); | |
| 97 | applyDotfiles(home, dotfiles); | |
| 98 | writeFresh(join(home, GREETING_FILE), greeting(facts)); | |
| 99 | if (lstatSync(historyPath(home), { throwIfNoEntry: false }) === undefined) writeFresh(historyPath(home), ""); | |
| 89 | 100 | } |
| 90 | 101 | |
| 91 | 102 | /** How long a first clone may take before git is killed and the shell starts in home. */ |
src/machines/shell.ts +6 −2
| @@ -44,6 +44,7 @@ import { readFileAt } from "../apps/checkout.ts"; | ||
| 44 | 44 | import { parseManifest } from "../apps/manifest.ts"; |
| 45 | 45 | import { resolveOid } from "../git/merge.ts"; |
| 46 | 46 | import { repoDir } from "../git/repos.ts"; |
| 47 | import { loadDotfiles, type DotfileSet } from "./dotfiles.ts"; | |
| 47 | 48 | import { ensureCheckout, prepareHome, shellPlan, CHECKOUT_DIR, type ShellPlan } from "./rc.ts"; |
| 48 | 49 | |
| 49 | 50 | // ---- the route ---- |
| @@ -86,6 +87,8 @@ export interface ShellGrant { | ||
| 86 | 87 | readonly sessionId: string | undefined; |
| 87 | 88 | /** The app's own `tty` command at the default branch's tip, for the greeting. */ |
| 88 | 89 | readonly tty?: string | null; |
| 90 | /** The person's dotfiles repository, read as them when the shell opened (#10548). */ | |
| 91 | readonly dotfiles?: DotfileSet; | |
| 89 | 92 | } |
| 90 | 93 | |
| 91 | 94 | /** |
| @@ -112,7 +115,7 @@ export async function mayOpenShell( | ||
| 112 | 115 | const config = await repoMachines(app, repo); |
| 113 | 116 | const machine = config.machines.find((m) => m.name === target.machine); |
| 114 | 117 | if (machine === undefined) return { ok: false, refusal: "not_found" }; |
| 115 | const grant = { repo, machine, user, sessionId: auth.sessionId, tty: await appTty(app, repo) }; | |
| 118 | const grant = { repo, machine, user, sessionId: auth.sessionId, tty: await appTty(app, repo), dotfiles: await loadDotfiles(app, user) }; | |
| 116 | 119 | if (!roomFor(app, grant)) return { ok: false, refusal: "busy" }; |
| 117 | 120 | // The first connection clones; async, so no other request waits on it. |
| 118 | 121 | await ensureCheckout( |
| @@ -497,7 +500,8 @@ function start(app: App, grant: ShellGrant, opts: ShellOptions, cols: number, ro | ||
| 497 | 500 | detaches: opts.detaches === true, |
| 498 | 501 | checkout: existsSync(join(dir, CHECKOUT_DIR, ".git")), |
| 499 | 502 | tty: grant.tty ?? null, |
| 500 | }); | |
| 503 | dotfilesUnreadable: grant.dotfiles?.unreadable ?? null, | |
| 504 | }, grant.dotfiles); | |
| 501 | 505 | row = recordStart(app.db, grant.repo.id, grant.machine.name, grant.user.id); |
| 502 | 506 | terminal = new Bun.Terminal({ |
| 503 | 507 | cols, |
src/tui/screens/dotfiles.ts added +70
| @@ -0,0 +1,70 @@ | ||
| 1 | /** | |
| 2 | * Your dotfiles repository, in the terminal: the repository whose top-level | |
| 3 | * dotfiles every shell you open gets, with `e` to name one and `x` to clear | |
| 4 | * it, through `setDotfilesRepo` — the call behind Settings › Shells, refusing | |
| 5 | * in the same words. | |
| 6 | */ | |
| 7 | ||
| 8 | import { DOTFILES_ERRORS, dotfilesRepoOf, setDotfilesRepo } from "../../machines/dotfiles.ts"; | |
| 9 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 10 | import { binding } from "../app/keys.ts"; | |
| 11 | import type { Session } from "../session.ts"; | |
| 12 | import { style } from "../term/style.ts"; | |
| 13 | import { truncate } from "../term/width.ts"; | |
| 14 | import { promptScreen } from "./common.ts"; | |
| 15 | import type { PaletteEntry } from "./entries.ts"; | |
| 16 | ||
| 17 | export function dotfilesScreen(f: Session, ctx: Ctx): Screen { | |
| 18 | const save = (text: string): string | undefined => { | |
| 19 | if (f.user === undefined) return undefined; | |
| 20 | const r = setDotfilesRepo(f.app.db, f.user, text); | |
| 21 | if (!r.ok) { | |
| 22 | const words = DOTFILES_ERRORS[r.error]; | |
| 23 | ctx.toast(words, "danger"); | |
| 24 | return words; | |
| 25 | } | |
| 26 | ctx.toast(r.value === "set" ? "Dotfiles repository saved." : "Dotfiles repository cleared.", "success"); | |
| 27 | ctx.invalidate(); | |
| 28 | return undefined; | |
| 29 | }; | |
| 30 | ||
| 31 | const screen: Screen = { | |
| 32 | title: "Your dotfiles", | |
| 33 | keys: [binding(["e"], "set repository"), binding(["x"], "clear")], | |
| 34 | render(area, s) { | |
| 35 | const c = ctx.theme.c; | |
| 36 | if (f.user === undefined) return; | |
| 37 | const named = dotfilesRepoOf(f.app.db, f.user.id); | |
| 38 | s.text(area.x, area.y, "Repository".padEnd(14), style(c.mutedFg, c.bg), area.x + area.w); | |
| 39 | s.text(area.x + 14, area.y, truncate(named ?? "none", Math.max(0, area.w - 14)), style(c.fg, c.bg), area.x + area.w); | |
| 40 | }, | |
| 41 | key(e) { | |
| 42 | if (e.ctrl || e.alt || f.user === undefined) return false; | |
| 43 | if (e.key === "e") { | |
| 44 | ctx.push( | |
| 45 | promptScreen(ctx, screen, { | |
| 46 | title: "Dotfiles repository", | |
| 47 | label: "owner/name", | |
| 48 | value: dotfilesRepoOf(f.app.db, f.user.id) ?? "", | |
| 49 | onSubmit: save, | |
| 50 | }), | |
| 51 | ); | |
| 52 | return true; | |
| 53 | } | |
| 54 | if (e.key === "x") { | |
| 55 | save(""); | |
| 56 | return true; | |
| 57 | } | |
| 58 | return false; | |
| 59 | }, | |
| 60 | }; | |
| 61 | return screen; | |
| 62 | } | |
| 63 | ||
| 64 | export const entry: PaletteEntry = { | |
| 65 | id: "dotfiles", | |
| 66 | title: "Your dotfiles", | |
| 67 | hint: "the repository every shell you open copies from", | |
| 68 | offered: (session) => session.user !== undefined && session.user.kind !== "agent", | |
| 69 | run: (session, ctx) => ctx.push(dotfilesScreen(session, ctx)), | |
| 70 | }; |
src/tui/screens/entries.ts +2
| @@ -14,6 +14,7 @@ import { entry as askData } from "./ask_data.ts"; | ||
| 14 | 14 | import { entry as archived } from "./archived.ts"; |
| 15 | 15 | import { entry as audit } from "./audit.ts"; |
| 16 | 16 | import { entry as appTemplates } from "./app_templates.ts"; |
| 17 | import { entry as dotfiles } from "./dotfiles.ts"; | |
| 17 | 18 | import { entry as devices } from "./devices.ts"; |
| 18 | 19 | import { entry as bets } from "./bets.ts"; |
| 19 | 20 | import { entry as boards } from "./boards.ts"; |
| @@ -145,6 +146,7 @@ export const ENTRIES: readonly PaletteEntry[] = [ | ||
| 145 | 146 | forges, |
| 146 | 147 | originAhead, |
| 147 | 148 | profile, |
| 149 | dotfiles, | |
| 148 | 150 | now, |
| 149 | 151 | forgeView, |
| 150 | 152 | repos, |
src/web/pages/account.ts +42 −1
| @@ -76,6 +76,7 @@ import { | ||
| 76 | 76 | icon, |
| 77 | 77 | } from "../ui.ts"; |
| 78 | 78 | import { STOPPED_ONE, myShells, stopIdleShells, stopShell, stoppedIdleSays } from "../../machines/shell.ts"; |
| 79 | import { DOTFILES_ERRORS, dotfilesRepoOf, setDotfilesRepo } from "../../machines/dotfiles.ts"; | |
| 79 | 80 | import { everydayLine } from "./everyday.ts"; |
| 80 | 81 | import { passkeyJs } from "../passkey-client.ts"; |
| 81 | 82 | |
| @@ -178,6 +179,46 @@ function notifyView(ctx: Ctx, error?: string): Html { | ||
| 178 | 179 | ); |
| 179 | 180 | } |
| 180 | 181 | |
| 182 | function dotfilesView(ctx: Ctx, error?: string): Html { | |
| 183 | const named = dotfilesRepoOf(ctx.app.db, ctx.auth.user!.id); | |
| 184 | return block( | |
| 185 | "Dotfiles", | |
| 186 | html`<form method="post" action="/settings/dotfiles"> | |
| 187 | ${uiField({ | |
| 188 | label: "Dotfiles repository", | |
| 189 | name: "repo", | |
| 190 | value: named ?? "", | |
| 191 | placeholder: "owner/name", | |
| 192 | autocomplete: "off", | |
| 193 | hint: "A repository on this forge that you can read; empty for none.", | |
outdated
Principle 1: the hint explains the feature in three sentences on a page whose label already says it. One sentence: 'A repository on this forge that you can read; empty for none.' The rest is in docs/features/dotfiles.md. |
||
| 194 | })} | |
| 195 | <div class="form-actions"> | |
| 196 | ${btn("Save dotfiles repository", { variant: "primary" })} | |
| 197 | </div> | |
| 198 | ${refusal(error)} | |
| 199 | </form>`, | |
| 200 | ); | |
| 201 | } | |
| 202 | ||
| 203 | // POST /settings/dotfiles | |
| 204 | export async function dotfilesUpdate(ctx: Ctx): Promise<Response> { | |
| 205 | if (!ctx.auth.user) return signInFirst(ctx); | |
| 206 | const form = await readForm(ctx, "admin"); | |
| 207 | if (form instanceof Response) return form; | |
| 208 | const saved = setDotfilesRepo(ctx.app.db, ctx.auth.user, field(form, "repo")); | |
| 209 | if (!saved.ok) | |
| 210 | return accountShell( | |
| 211 | ctx, | |
| 212 | "shells", | |
| 213 | "Shells", | |
| 214 | html`${dotfilesView(ctx, DOTFILES_ERRORS[saved.error])}${shellsView(ctx, ctx.auth.user)}`, | |
| 215 | 422, | |
| 216 | ); | |
| 217 | return flashTo(ctx, "/settings/shells", { | |
| 218 | message: saved.value === "set" ? "Dotfiles repository saved." : "Dotfiles repository cleared.", | |
| 219 | }); | |
| 220 | } | |
| 221 | ||
| 181 | 222 | /** open-platform.sh: linked as <login>, or not, with Link and Unlink. */ |
| 182 | 223 | function edgeLinkView(ctx: Ctx): Html { |
| 183 | 224 | const token = activeEdgeToken(ctx.app); |
| @@ -439,7 +480,7 @@ export function shellsView(ctx: Ctx, user: User): Html { | ||
| 439 | 480 | export function shellsPage(ctx: Ctx): Response { |
| 440 | 481 | if (!ctx.auth.user) return signInFirst(ctx); |
| 441 | 482 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| 442 | const body = shellsView(ctx, ctx.auth.user); | |
| 483 | const body = html`${dotfilesView(ctx)}${shellsView(ctx, ctx.auth.user)}`; | |
| 443 | 484 | return timePhase(ctx.request, "render", () => accountShell(ctx, "shells", "Shells", body)); |
| 444 | 485 | } |
| 445 | 486 |
test/account-nojs.test.ts +2 −1
| @@ -99,6 +99,7 @@ test("the page content (<main>, the shell excluded) depends on no script or inli | ||
| 99 | 99 | const EXPECTED = [ |
| 100 | 100 | "/settings", // profile |
| 101 | 101 | "/settings/notify", |
| 102 | "/settings/dotfiles", | |
| 102 | 103 | "/settings/everyday", |
| 103 | 104 | "/settings/everyday/keep", |
| 104 | 105 | "/settings/email/verify", // shown to an unverified user only: bob |
| @@ -111,7 +112,7 @@ const EXPECTED = [ | ||
| 111 | 112 | |
| 112 | 113 | test("every form is a plain post form: the expected set is rendered and each refuses a cross-origin post", async () => { |
| 113 | 114 | const found = new Set<string>(); |
| 114 | for (const path of ["/settings", "/settings/tokens", "/settings/keys", "/settings/password", "bob"]) { | |
| 115 | for (const path of ["/settings", "/settings/tokens", "/settings/keys", "/settings/password", "/settings/shells", "bob"]) { | |
| 115 | 116 | const html = await (path === "bob" |
| 116 | 117 | ? fetch(servers.url + "/settings", { headers: { cookie: bobCookie } }) |
| 117 | 118 | : get(path) |
test/dotfiles-route.test.ts added +97
| @@ -0,0 +1,97 @@ | ||
| 1 | /** | |
| 2 | * POST /settings/dotfiles (#10548): a saved repository flashes on the Shells | |
| 3 | * page, an empty one clears, a refused one renders 422 in words, and a | |
| 4 | * cross-origin post changes nothing. | |
| 5 | */ | |
| 6 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 7 | import { mkdtempSync, rmSync } from "node:fs"; | |
| 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/core/repos.ts"; | |
| 12 | import { createUser } from "../src/core/users.ts"; | |
| 13 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 14 | import { DOTFILES_ERRORS, dotfilesRepoOf } from "../src/machines/dotfiles.ts"; | |
| 15 | import { proveAll } from "./helpers/verified.ts"; | |
| 16 | import { quietLogs } from "./helpers/globals.ts"; | |
| 17 | ||
| 18 | quietLogs(); | |
| 19 | ||
| 20 | let root = ""; | |
| 21 | let app: App; | |
| 22 | let servers: Servers; | |
| 23 | let cookie = ""; | |
| 24 | let aliceId = ""; | |
| 25 | ||
| 26 | const post = (repo: string, origin: string) => | |
| 27 | fetch(`${servers.url}/settings/dotfiles`, { | |
| 28 | method: "POST", | |
| 29 | headers: { cookie, origin }, | |
| 30 | body: new URLSearchParams({ repo }), | |
| 31 | redirect: "manual", | |
| 32 | }); | |
| 33 | ||
| 34 | beforeAll(async () => { | |
| 35 | root = mkdtempSync(join(tmpdir(), "forge-dotfiles-route-")); | |
| 36 | app = createApp({ dataDir: root, port: 0, forgeRepo: "alice/forge" }); | |
| 37 | const u = await createUser(app.db, { | |
| 38 | origin: "seed", trusted: true, username: "alice", email: "[email protected]", | |
| 39 | password: "a-good-passphrase", adminIfFirst: true, | |
| 40 | }); | |
| 41 | if (!u.ok) throw new Error(u.error); | |
| 42 | aliceId = u.value.id; | |
| 43 | proveAll(app); | |
| 44 | const made = await createRepo(app.db, app.reposRoot, { ownerId: aliceId, name: "dots", visibility: "public" }); | |
| 45 | if (!made.ok) throw new Error(String(made.error)); | |
| 46 | servers = await serve(app); | |
| 47 | const signin = await fetch(`${servers.url}/signin`, { | |
| 48 | method: "POST", | |
| 49 | headers: { origin: servers.url }, | |
| 50 | body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }), | |
| 51 | redirect: "manual", | |
| 52 | }); | |
| 53 | cookie = (signin.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 54 | }); | |
| 55 | afterAll(() => { | |
| 56 | servers.stop(); | |
| 57 | app.close(); | |
| 58 | rmSync(root, { recursive: true, force: true }); | |
| 59 | }); | |
| 60 | ||
| 61 | test("the form is on the Shells page, not on the settings first view", async () => { | |
| 62 | const shells = await (await fetch(`${servers.url}/settings/shells`, { headers: { cookie } })).text(); | |
| 63 | expect(shells).toContain('action="/settings/dotfiles"'); | |
| 64 | const settings = await (await fetch(`${servers.url}/settings`, { headers: { cookie } })).text(); | |
| 65 | expect(settings).not.toContain('action="/settings/dotfiles"'); | |
| 66 | }); | |
| 67 | ||
| 68 | test("a readable repository is saved and the redirect says so", async () => { | |
| 69 | const res = await post("alice/dots", servers.url); | |
| 70 | expect(res.status).toBe(303); | |
| 71 | expect(res.headers.get("location")).toBe("/settings/shells"); | |
| 72 | expect(dotfilesRepoOf(app.db, aliceId)).toBe("alice/dots"); | |
| 73 | const page = await (await fetch(`${servers.url}/settings/shells`, { headers: { cookie } })).text(); | |
| 74 | expect(page).toContain('value="alice/dots"'); | |
| 75 | }); | |
| 76 | ||
| 77 | test("a refused repository renders 422 with the words, and the saved choice stays", async () => { | |
| 78 | const missing = await post("alice/nothing", servers.url); | |
| 79 | expect(missing.status).toBe(422); | |
| 80 | expect(await missing.text()).toContain(DOTFILES_ERRORS.not_found); | |
| 81 | const bad = await post("../etc", servers.url); | |
| 82 | expect(bad.status).toBe(422); | |
| 83 | expect(await bad.text()).toContain(DOTFILES_ERRORS.invalid); | |
| 84 | expect(dotfilesRepoOf(app.db, aliceId)).toBe("alice/dots"); | |
| 85 | }); | |
| 86 | ||
| 87 | test("a cross-origin post is refused and changes nothing", async () => { | |
| 88 | const res = await post("", "https://evil.example"); | |
| 89 | expect(res.status).toBe(403); | |
| 90 | expect(dotfilesRepoOf(app.db, aliceId)).toBe("alice/dots"); | |
| 91 | }); | |
| 92 | ||
| 93 | test("an empty name clears", async () => { | |
| 94 | const res = await post("", servers.url); | |
| 95 | expect(res.status).toBe(303); | |
| 96 | expect(dotfilesRepoOf(app.db, aliceId)).toBeUndefined(); | |
| 97 | }); |
test/machine-rc.test.ts +101 −2
| @@ -3,7 +3,7 @@ | ||
| 3 | 3 | * history lives under the data directory and is there in the next session. |
| 4 | 4 | */ |
| 5 | 5 | import { afterAll, beforeAll, expect, test } from "bun:test"; |
| 6 | import { existsSync, mkdirSync, mkdtempSync, readFileSync } from "node:fs"; | |
| 6 | import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, symlinkSync } from "node:fs"; | |
| 7 | 7 | import { tmpdir } from "node:os"; |
| 8 | 8 | import { dirname, join } from "node:path"; |
| 9 | 9 | import { createApp, type App } from "../src/app.ts"; |
| @@ -13,7 +13,7 @@ import { commitFiles } from "../src/git/write.ts"; | ||
| 13 | 13 | import { repoDir } from "../src/git/repos.ts"; |
| 14 | 14 | import { BUILT_IN } from "../src/crew/machines.ts"; |
| 15 | 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"; | |
| 16 | import { appTty, mayOpenShell, shellEnv, workspace } from "../src/machines/shell.ts"; | |
| 17 | 17 | import { cellMounts, keptPaths } from "../src/apps/contain.ts"; |
| 18 | 18 | |
| 19 | 19 | const facts = { repo: "alice/lab", machine: "standard", detaches: true, checkout: true, tty: "bun run tty.ts" }; |
| @@ -230,3 +230,102 @@ test("without bash, sh gets the greeting, the checkout and a repository prompt", | ||
| 230 | 230 | expect(out).toContain(join(dir, CHECKOUT_DIR)); |
| 231 | 231 | expect(out).toContain("alice/lab $ "); |
| 232 | 232 | }); |
| 233 | ||
| 234 | // ---- dotfiles from a repository the person names (#10548) ---- | |
No test drives dotfilesUpdate (the POST route, its 422 render, its flash) and none goes through mayOpenShell/start to show the grant carries the set into the home. Add both; as is, deleting the route wiring or the shell.ts change would leave the tests green. |
||
| 235 | ||
| 236 | import { MANIFEST_FILE, loadDotfiles, parseDotfilesRepo, setDotfilesRepo, dotfilesRepoOf } from "../src/machines/dotfiles.ts"; | |
| 237 | import { getUserById } from "../src/core/users.ts"; | |
| 238 | import { readFileSync as readText } from "node:fs"; | |
| 239 | ||
| 240 | test("dotfiles: the top-level dotfiles land in the home, the rc sources .bashrc after its defaults", async () => { | |
| 241 | const made = await createRepo(app.db, app.reposRoot, { ownerId: userId, name: "dots", visibility: "public" }); | |
| 242 | if (!made.ok) throw new Error(String(made.error)); | |
| 243 | const c = await commitFiles(repoDir(app.reposRoot, "alice", "dots"), { | |
| 244 | branch: made.value.defaultBranch, | |
| 245 | message: "dots", | |
| 246 | files: [ | |
| 247 | { path: ".bashrc", content: "alias ll='ls -l'\n" }, | |
| 248 | { path: ".gitconfig", content: "[user]\nname = A\n" }, | |
| 249 | { path: ".forge_rc", content: "echo pwned\n" }, | |
| 250 | { path: "README.md", content: "not a dotfile\n" }, | |
| 251 | { path: ".config/nested", content: "no\n" }, | |
| 252 | ], | |
| 253 | author: { name: "T", email: "[email protected]" }, | |
| 254 | }); | |
| 255 | if (!c.ok) throw new Error(JSON.stringify(c.error)); | |
| 256 | const user = getUserById(app.db, userId)!; | |
| 257 | expect(setDotfilesRepo(app.db, user, "alice/dots").ok).toBe(true); | |
| 258 | const set = await loadDotfiles(app, user); | |
| 259 | expect(set.unreadable).toBeNull(); | |
| 260 | const home = join(workspace(app, repo, standard(), userId), "home-dots"); | |
| 261 | prepareHome(home, facts, set); | |
| 262 | expect(readText(join(home, ".bashrc"), "utf8")).toContain("alias ll="); | |
| 263 | expect(existsSync(join(home, ".gitconfig"))).toBe(true); | |
| 264 | expect(existsSync(join(home, "README.md"))).toBe(false); | |
| 265 | expect(existsSync(join(home, ".config"))).toBe(false); | |
| 266 | expect(readText(join(home, ".forge_rc"), "utf8")).toBe(RC); | |
| 267 | expect(RC.indexOf("alias ll=")).toBeLessThan(RC.indexOf('. "$HOME/.bashrc"')); | |
| 268 | // Clearing the setting removes what the last session copied. | |
| 269 | prepareHome(home, facts); | |
| 270 | expect(existsSync(join(home, ".bashrc"))).toBe(false); | |
| 271 | }); | |
| 272 | ||
| 273 | test("dotfiles: an unreadable or missing repository is refused, named once in the greeting, and the shell still opens", async () => { | |
| 274 | const bob = await createUser(app.db, { username: "bob", email: "[email protected]", password: "correct horse battery", origin: "seed", isAdmin: false, trusted: true }); | |
| 275 | if (!bob.ok) throw new Error(String(bob.error)); | |
| 276 | const priv = await createRepo(app.db, app.reposRoot, { ownerId: userId, name: "secret-dots", visibility: "private" }); | |
| 277 | if (!priv.ok) throw new Error(String(priv.error)); | |
| 278 | expect(setDotfilesRepo(app.db, bob.value, "alice/secret-dots")).toEqual({ ok: false, error: "not_found" }); | |
| 279 | expect(setDotfilesRepo(app.db, bob.value, "alice/nothing")).toEqual({ ok: false, error: "not_found" }); | |
| 280 | expect(setDotfilesRepo(app.db, bob.value, "../etc/passwd")).toEqual({ ok: false, error: "invalid" }); | |
| 281 | expect(parseDotfilesRepo("a/b/c").ok).toBe(false); | |
| 282 | // A choice saved earlier whose repository is gone: the shell opens with a note. | |
| 283 | app.db.query("INSERT INTO user_setting (user_id, key, value) VALUES (?1, 'dotfiles_repo', 'alice/gone')").run(bob.value.id); | |
| 284 | expect(dotfilesRepoOf(app.db, bob.value.id)).toBe("alice/gone"); | |
| 285 | const set = await loadDotfiles(app, bob.value); | |
| 286 | expect(set.files).toEqual([]); | |
| 287 | expect(set.unreadable).toBe("alice/gone"); | |
| 288 | const home = join(mkdtempSync(join(tmpdir(), "rc-home-")), "home"); | |
| 289 | prepareHome(home, { ...facts, dotfilesUnreadable: set.unreadable }, set); | |
| 290 | const said = readText(join(home, ".forge_greeting"), "utf8"); | |
| 291 | expect(said.split("alice/gone").length).toBe(2); | |
| 292 | expect(existsSync(join(home, ".forge_rc"))).toBe(true); | |
| 293 | }); | |
| 294 | ||
| 295 | test("dotfiles: a symlink planted in the home is replaced, never followed, by the forge's writes", () => { | |
| 296 | const home = join(mkdtempSync(join(tmpdir(), "rc-link-")), "home"); | |
| 297 | mkdirSync(home, { recursive: true }); | |
| 298 | const victim = join(dirname(home), "victim"); | |
| 299 | Bun.write(victim, "keep me\n"); | |
| 300 | const body = new TextEncoder().encode("alias a=b\n"); | |
| 301 | for (const name of [".bashrc", MANIFEST_FILE, RC_FILE, ".forge_greeting", ".forge_history"]) | |
| 302 | symlinkSync(victim, join(home, name)); | |
| 303 | prepareHome(home, facts, { files: [{ name: ".bashrc", body }], unreadable: null }); | |
| 304 | expect(readText(victim, "utf8")).toBe("keep me\n"); | |
| 305 | for (const name of [".bashrc", MANIFEST_FILE, RC_FILE, ".forge_greeting"]) | |
| 306 | expect(lstatSync(join(home, name)).isSymbolicLink()).toBe(false); | |
| 307 | expect(readText(join(home, ".bashrc"), "utf8")).toBe("alias a=b\n"); | |
| 308 | // A dangling link in the history's place is not written through either. | |
| 309 | const gone = join(dirname(home), "gone"); | |
| 310 | const h2 = join(dirname(home), "home2"); | |
| 311 | mkdirSync(h2); | |
| 312 | symlinkSync(gone, historyPath(h2)); | |
| 313 | prepareHome(h2, facts); | |
| 314 | expect(existsSync(gone)).toBe(false); | |
| 315 | }); | |
| 316 | ||
| 317 | test("dotfiles: the shell grant carries the person's set, which then lands in the home", async () => { | |
| 318 | const user = getUserById(app.db, userId)!; | |
| 319 | expect(setDotfilesRepo(app.db, user, "alice/dots").ok).toBe(true); | |
| 320 | const url = new URL("http://forge.test"); | |
| 321 | const asked = await mayOpenShell(app, new Request(url.href, { headers: { origin: url.origin } }), url, | |
| 322 | { owner: "alice", name: "lab", machine: "standard" }, { user, via: "session" }); | |
| 323 | if (!asked.ok) throw new Error(asked.refusal); | |
| 324 | expect(asked.grant.dotfiles?.files.map((f) => f.name)).toContain(".bashrc"); | |
| 325 | const none = getUserById(app.db, userId)!; | |
| 326 | expect(setDotfilesRepo(app.db, none, "")).toEqual({ ok: true, value: "cleared" }); | |
| 327 | const again = await mayOpenShell(app, new Request(url.href, { headers: { origin: url.origin } }), url, | |
| 328 | { owner: "alice", name: "lab", machine: "standard" }, { user: none, via: "session" }); | |
| 329 | if (!again.ok) throw new Error(again.refusal); | |
| 330 | expect(again.grant.dotfiles?.files ?? []).toEqual([]); | |
| 331 | }); |
test/palette-roster.test.ts +2
| @@ -166,6 +166,7 @@ test("a site-admin person is offered the whole roster", () => { | ||
| 166 | 166 | ["forges", "Forges you sent", "each forge sent, claimed or not, and its apps"], |
| 167 | 167 | ["origin-ahead", "New commits upstream", "mirrors whose origin has moved on: take them"], |
| 168 | 168 | ["profile", "Your profile", "display name and email"], |
| 169 | ["dotfiles", "Your dotfiles", "the repository every shell you open copies from"], | |
| 169 | 170 | ["family", "Family", "the forge that sent this one, and the forges it sent"], |
| 170 | 171 | ["chain", "Chain", "the forges this forge's code descends from, root first"], |
| 171 | 172 | ["quit", "Quit", "disconnect"], |
| @@ -316,6 +317,7 @@ test("a member person is offered the signed-in roster without the admin entries" | ||
| 316 | 317 | ["front", "Front page", "the apps a first visitor sees, ranked; an admin pins or hides"], |
| 317 | 318 | ["origin-ahead", "New commits upstream", "mirrors whose origin has moved on: take them"], |
| 318 | 319 | ["profile", "Your profile", "display name and email"], |
| 320 | ["dotfiles", "Your dotfiles", "the repository every shell you open copies from"], | |
| 319 | 321 | ["forge-view", "Forge view", "the forge's counts, and Foundry when it is installed"], |
| 320 | 322 | ["family", "Family", "the forge that sent this one, and the forges it sent"], |
| 321 | 323 | ["chain", "Chain", "the forges this forge's code descends from, root first"], |
test/pull-quiet.test.ts +2 −2
| @@ -66,8 +66,8 @@ afterAll(async () => { | ||
| 66 | 66 | const get = async (path: string) => (await fetch(`${servers.url}${path}`, { headers: { cookie } })).text(); |
| 67 | 67 | const bytes = (t: string) => new TextEncoder().encode(t).length; |
| 68 | 68 | |
| 69 | // Measured ~31,650 (pull: 31,130 on main plus the verdict line and the change above the check) and 25,064 (list) with the Displays entry in the account menu and palette. Each budget is that plus ~160 B, the spread the port digits and times in the page showed, so it holds the page from growing and is not a tighter squeeze. The palette now also carries the people, three settings rows and the Tell-the-crew action (#10659): 31,869 measured on the pull page, so the pull budget is that plus ~160. | |
| 70 | const BUDGET = { pull: 32_030, list: 25_400 }; | |
| 69 | // Measured ~31,650 (pull: 31,130 on main plus the verdict line and the change above the check) and 25,064 (list) with the Displays entry in the account menu and palette. Each budget is that plus ~160 B, the spread the port digits and times in the page showed, so it holds the page from growing and is not a tighter squeeze. With the Your-dotfiles palette entry (#10548) the list measures 25,260, budget that plus ~170. The palette now also carries the people, three settings rows and the Tell-the-crew action (#10659): 31,869 measured on the pull page, so the pull budget is that plus ~160. | |
| 70 | const BUDGET = { pull: 32_030, list: 25_430 }; | |
| 71 | 71 | |
| 72 | 72 | test("the pull page's check is a line with a dot and its word, not a card around a badge", async () => { |
| 73 | 73 | const text = await get(`/alice/r/pulls/${number}`); |
test/tui-dotfiles.test.ts added +101
| @@ -0,0 +1,101 @@ | ||
| 1 | /** | |
| 2 | * Your dotfiles in the terminal (#10548): `e` names the repository and `x` | |
| 3 | * clears it through setDotfilesRepo, refusing in the page's words; a guest | |
| 4 | * and an agent are not offered the screen. | |
| 5 | */ | |
| 6 | import { afterAll, beforeAll, describe, 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 as Forge } from "../src/app.ts"; | |
| 11 | import { createRepo } from "../src/core/repos.ts"; | |
| 12 | import { createUser, getUserByName } from "../src/core/users.ts"; | |
| 13 | import { DOTFILES_ERRORS, dotfilesRepoOf } from "../src/machines/dotfiles.ts"; | |
| 14 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 15 | import { type TuiIdentity } from "../src/tui/session.ts"; | |
| 16 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 17 | import { quietLogs } from "./helpers/globals.ts"; | |
| 18 | import { settleApp } from "./helpers/tui.ts"; | |
| 19 | ||
| 20 | quietLogs(); | |
| 21 | ||
| 22 | describe("your dotfiles in the terminal", () => { | |
| 23 | let dataDir: string; | |
| 24 | let forge: Forge; | |
| 25 | ||
| 26 | function boot(username: string | undefined) { | |
| 27 | const terminal = headlessTerminal(140, 32); | |
| 28 | const user = username === undefined ? undefined : getUserByName(forge.db, username)!; | |
| 29 | const identity: TuiIdentity = { sshUsername: "t", remoteAddress: "127.0.0.1", user }; | |
| 30 | const { app } = createForgeApp(forge, identity, { terminal, exitHooks: false }); | |
| 31 | app.start(); | |
| 32 | const key = (k: string) => app.dispatch({ type: "key", key: k, ctrl: false, alt: false, shift: false }); | |
| 33 | return { | |
| 34 | text: () => app.screen.toText(), | |
| 35 | press: async (...keys: string[]) => { keys.forEach(key); await settleApp(app); }, | |
| 36 | ctrl: async (k: string) => { | |
| 37 | app.dispatch({ type: "key", key: k, ctrl: true, alt: false, shift: false }); | |
| 38 | await settleApp(app); | |
| 39 | }, | |
| 40 | type: async (t: string) => { for (const ch of t) key(ch); await settleApp(app); }, | |
| 41 | stop: () => app.stop(), | |
| 42 | }; | |
| 43 | } | |
| 44 | ||
| 45 | beforeAll(async () => { | |
| 46 | dataDir = await mkdtemp(join(tmpdir(), "forge-tui-dotfiles-")); | |
| 47 | forge = createApp({ dataDir, port: 0 }); | |
| 48 | for (const [username, adminIfFirst] of [["alice", true], ["carol", false]] as const) { | |
| 49 | const made = await createUser(forge.db, { | |
| 50 | origin: "seed", trusted: true, username, email: `${username}@forge.local`, | |
| 51 | password: "a-good-passphrase", adminIfFirst, | |
| 52 | }); | |
| 53 | if (!made.ok) throw new Error(made.error); | |
| 54 | } | |
| 55 | forge.db.query("UPDATE user SET is_agent = 1 WHERE username = 'carol'").run(); | |
| 56 | const alice = getUserByName(forge.db, "alice")!; | |
| 57 | const made = await createRepo(forge.db, forge.reposRoot, { ownerId: alice.id, name: "dots", visibility: "public" }); | |
| 58 | if (!made.ok) throw new Error(String(made.error)); | |
| 59 | }); | |
| 60 | afterAll(async () => { | |
| 61 | forge.close(); | |
| 62 | await rm(dataDir, { recursive: true, force: true }); | |
| 63 | }); | |
| 64 | ||
| 65 | const open = async (h: ReturnType<typeof boot>) => { | |
| 66 | await h.ctrl("k"); | |
| 67 | await h.type("Your dotfiles"); | |
| 68 | await h.press("enter"); | |
| 69 | }; | |
| 70 | ||
| 71 | test("e names a repository, a refusal is the page's words, x clears", async () => { | |
| 72 | const alice = getUserByName(forge.db, "alice")!; | |
| 73 | const h = boot("alice"); | |
| 74 | await open(h); | |
| 75 | expect(h.text()).toContain("none"); | |
| 76 | await h.press("e"); | |
| 77 | await h.type("alice/nothing"); | |
| 78 | await h.press("enter"); | |
| 79 | expect(h.text()).toContain(DOTFILES_ERRORS.not_found); | |
| 80 | expect(dotfilesRepoOf(forge.db, alice.id)).toBeUndefined(); | |
| 81 | for (let i = 0; i < 20; i++) await h.press("backspace"); | |
| 82 | await h.type("alice/dots"); | |
| 83 | await h.press("enter"); | |
| 84 | expect(dotfilesRepoOf(forge.db, alice.id)).toBe("alice/dots"); | |
| 85 | expect(h.text()).toContain("alice/dots"); | |
| 86 | await h.press("x"); | |
| 87 | expect(dotfilesRepoOf(forge.db, alice.id)).toBeUndefined(); | |
| 88 | expect(h.text()).toContain("none"); | |
| 89 | h.stop(); | |
| 90 | }); | |
| 91 | ||
| 92 | test("a guest and an agent are not offered the screen", async () => { | |
| 93 | for (const who of [undefined, "carol"]) { | |
| 94 | const h = boot(who); | |
| 95 | await h.ctrl("k"); | |
| 96 | await h.type("Your dotfiles"); | |
| 97 | expect(h.text()).not.toContain("the repository every shell"); | |
| 98 | h.stop(); | |
| 99 | } | |
| 100 | }); | |
| 101 | }); |
Both doors: this capability has no screen in src/tui/. Add one over setDotfilesRepo/dotfilesRepoOf, with its entry per AGENTS.md, and a test; a write there must give the same refusals as the page.