Public: anyone can read this; the crew can change it.

Your dotfiles from a repository you name are in every shell you open #10721

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/10548-your-dotfiles-from-a-repository-you-name → main 4 commits fork 4 threads · 4 unresolved

  • Merged. scout/10548-your-dotfiles-from-a-repository-you-name was merged into main as 22a2d85.

Review

Sign in to review this change.
14 files changed +603 −14
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 {
9999 edgeUnlink,
100100 parentUnlink,
101101 accountUpdate,
102 dotfilesUpdate,
102103 keyCreate,
103104 keyDelete,
104105 keysPage,
@@ -140,6 +141,7 @@ export function settingsRoutes(router: Router<Handler>, knownPatterns: () => rea
140141 router.add("GET", "/settings", accountPage);
141142 router.add("POST", "/settings", accountUpdate);
142143 router.add("POST", "/settings/notify", notifyUpdate);
144 router.add("POST", "/settings/dotfiles", dotfilesUpdate);
143145 router.add("POST", "/settings/edge/link", edgeLink);
144146 router.add("POST", "/settings/edge/unlink", edgeUnlink);
145147 router.add("POST", "/settings/parent/link", parentLinkStart);
src/machines/dotfiles.ts added +146
@@ -0,0 +1,146 @@
1 /**

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.

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 @@
1414 * repository and machine, and written after every command so a tab closed
1515 * mid-session loses nothing.
1616 */
17 import { mkdirSync, writeFileSync, existsSync, renameSync, rmSync } from "node:fs";
17 import { mkdirSync, lstatSync, existsSync, renameSync, rmSync } from "node:fs";
1818 import { join } from "node:path";
1919 import { gitEnv } from "../git/env.ts";
20 import { applyDotfiles, writeFresh, NO_DOTFILES, type DotfileSet } from "./dotfiles.ts";
2021
2122 export const RC_FILE = ".forge_rc";
2223 export const GREETING_FILE = ".forge_greeting";
@@ -24,7 +25,11 @@ export const HISTORY_FILE = ".forge_history";
2425 /** The checkout's directory under the person's workspace. */
2526 export const CHECKOUT_DIR = "checkout";
2627
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 */
2833 export const RC = `# The forge's machine shell rc. Written by the forge; edits are replaced.
2934 case $- in *i*) ;; *) return ;; esac
3035 export HISTFILE="$HOME/${HISTORY_FILE}"
@@ -41,6 +46,7 @@ forge_branch() {
4146 git symbolic-ref --short -q HEAD 2>/dev/null || git rev-parse --short HEAD 2>/dev/null
4247 }
4348 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
4450 if [ -d "$FORGE_CHECKOUT" ]; then cd "$FORGE_CHECKOUT"; fi
4551 if [ -f "$HOME/${GREETING_FILE}" ]; then cat "$HOME/${GREETING_FILE}"; fi
4652 `;
@@ -59,6 +65,8 @@ export interface GreetingFacts {
5965 readonly checkout: boolean;
6066 /** The app's own `tty` command from forge.json, when it has one. */
6167 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;
6270 }
6371
6472 /** The greeting, in plain words: where you are and how to leave. */
@@ -72,6 +80,8 @@ export function greeting(facts: GreetingFacts): string {
7280 ];
7381 if (facts.tty !== null && plain(facts.tty) !== "")
7482 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.`);
7585 return `${lines.join("\n")}\n\n`;
7686 }
7787
@@ -81,11 +91,12 @@ export function historyPath(home: string): string {
8191 }
8292
8393 /** 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 {
8595 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), "");
89100 }
90101
91102 /** 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";
4444 import { parseManifest } from "../apps/manifest.ts";
4545 import { resolveOid } from "../git/merge.ts";
4646 import { repoDir } from "../git/repos.ts";
47 import { loadDotfiles, type DotfileSet } from "./dotfiles.ts";
4748 import { ensureCheckout, prepareHome, shellPlan, CHECKOUT_DIR, type ShellPlan } from "./rc.ts";
4849
4950 // ---- the route ----
@@ -86,6 +87,8 @@ export interface ShellGrant {
8687 readonly sessionId: string | undefined;
8788 /** The app's own `tty` command at the default branch's tip, for the greeting. */
8889 readonly tty?: string | null;
90 /** The person's dotfiles repository, read as them when the shell opened (#10548). */
91 readonly dotfiles?: DotfileSet;
8992 }
9093
9194 /**
@@ -112,7 +115,7 @@ export async function mayOpenShell(
112115 const config = await repoMachines(app, repo);
113116 const machine = config.machines.find((m) => m.name === target.machine);
114117 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) };
116119 if (!roomFor(app, grant)) return { ok: false, refusal: "busy" };
117120 // The first connection clones; async, so no other request waits on it.
118121 await ensureCheckout(
@@ -497,7 +500,8 @@ function start(app: App, grant: ShellGrant, opts: ShellOptions, cols: number, ro
497500 detaches: opts.detaches === true,
498501 checkout: existsSync(join(dir, CHECKOUT_DIR, ".git")),
499502 tty: grant.tty ?? null,
500 });
503 dotfilesUnreadable: grant.dotfiles?.unreadable ?? null,
504 }, grant.dotfiles);
501505 row = recordStart(app.db, grant.repo.id, grant.machine.name, grant.user.id);
502506 terminal = new Bun.Terminal({
503507 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";
1414 import { entry as archived } from "./archived.ts";
1515 import { entry as audit } from "./audit.ts";
1616 import { entry as appTemplates } from "./app_templates.ts";
17 import { entry as dotfiles } from "./dotfiles.ts";
1718 import { entry as devices } from "./devices.ts";
1819 import { entry as bets } from "./bets.ts";
1920 import { entry as boards } from "./boards.ts";
@@ -145,6 +146,7 @@ export const ENTRIES: readonly PaletteEntry[] = [
145146 forges,
146147 originAhead,
147148 profile,
149 dotfiles,
148150 now,
149151 forgeView,
150152 repos,
src/web/pages/account.ts +42 −1
@@ -76,6 +76,7 @@ import {
7676 icon,
7777 } from "../ui.ts";
7878 import { STOPPED_ONE, myShells, stopIdleShells, stopShell, stoppedIdleSays } from "../../machines/shell.ts";
79 import { DOTFILES_ERRORS, dotfilesRepoOf, setDotfilesRepo } from "../../machines/dotfiles.ts";
7980 import { everydayLine } from "./everyday.ts";
8081 import { passkeyJs } from "../passkey-client.ts";
8182
@@ -178,6 +179,46 @@ function notifyView(ctx: Ctx, error?: string): Html {
178179 );
179180 }
180181
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
181222 /** open-platform.sh: linked as <login>, or not, with Link and Unlink. */
182223 function edgeLinkView(ctx: Ctx): Html {
183224 const token = activeEdgeToken(ctx.app);
@@ -439,7 +480,7 @@ export function shellsView(ctx: Ctx, user: User): Html {
439480 export function shellsPage(ctx: Ctx): Response {
440481 if (!ctx.auth.user) return signInFirst(ctx);
441482 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)}`;
443484 return timePhase(ctx.request, "render", () => accountShell(ctx, "shells", "Shells", body));
444485 }
445486
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
9999 const EXPECTED = [
100100 "/settings", // profile
101101 "/settings/notify",
102 "/settings/dotfiles",
102103 "/settings/everyday",
103104 "/settings/everyday/keep",
104105 "/settings/email/verify", // shown to an unverified user only: bob
@@ -111,7 +112,7 @@ const EXPECTED = [
111112
112113 test("every form is a plain post form: the expected set is rendered and each refuses a cross-origin post", async () => {
113114 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"]) {
115116 const html = await (path === "bob"
116117 ? fetch(servers.url + "/settings", { headers: { cookie: bobCookie } })
117118 : 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 @@
33 * history lives under the data directory and is there in the next session.
44 */
55 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";
77 import { tmpdir } from "node:os";
88 import { dirname, join } from "node:path";
99 import { createApp, type App } from "../src/app.ts";
@@ -13,7 +13,7 @@ import { commitFiles } from "../src/git/write.ts";
1313 import { repoDir } from "../src/git/repos.ts";
1414 import { BUILT_IN } from "../src/crew/machines.ts";
1515 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";
1717 import { cellMounts, keptPaths } from "../src/apps/contain.ts";
1818
1919 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",
230230 expect(out).toContain(join(dir, CHECKOUT_DIR));
231231 expect(out).toContain("alice/lab $ ");
232232 });
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", () => {
166166 ["forges", "Forges you sent", "each forge sent, claimed or not, and its apps"],
167167 ["origin-ahead", "New commits upstream", "mirrors whose origin has moved on: take them"],
168168 ["profile", "Your profile", "display name and email"],
169 ["dotfiles", "Your dotfiles", "the repository every shell you open copies from"],
169170 ["family", "Family", "the forge that sent this one, and the forges it sent"],
170171 ["chain", "Chain", "the forges this forge's code descends from, root first"],
171172 ["quit", "Quit", "disconnect"],
@@ -316,6 +317,7 @@ test("a member person is offered the signed-in roster without the admin entries"
316317 ["front", "Front page", "the apps a first visitor sees, ranked; an admin pins or hides"],
317318 ["origin-ahead", "New commits upstream", "mirrors whose origin has moved on: take them"],
318319 ["profile", "Your profile", "display name and email"],
320 ["dotfiles", "Your dotfiles", "the repository every shell you open copies from"],
319321 ["forge-view", "Forge view", "the forge's counts, and Foundry when it is installed"],
320322 ["family", "Family", "the forge that sent this one, and the forges it sent"],
321323 ["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 () => {
6666 const get = async (path: string) => (await fetch(`${servers.url}${path}`, { headers: { cookie } })).text();
6767 const bytes = (t: string) => new TextEncoder().encode(t).length;
6868
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 };
7171
7272 test("the pull page's check is a line with a dot and its word, not a card around a badge", async () => {
7373 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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs