diff --git a/.changeset/auto-onboard-installs.md b/.changeset/auto-onboard-installs.md new file mode 100644 index 000000000..e2975946e --- /dev/null +++ b/.changeset/auto-onboard-installs.md @@ -0,0 +1,5 @@ +--- +'@gemstack/the-framework': patch +--- + +The repos-directory auto-scan is removed (#1600): the daemon no longer installs and registers every repo under a configured directory at boot (the `reposDirectory`/`reposDirectoryAutoGrant` preferences are gone with it) — the dashboard's "Add project" is the one onboarding path, so a repo is always installed before any agent touches it. Two fixes ride along: activation now means the install-written `.the-framework/.gitignore` exists (not merely the `.the-framework/` directory), and the repo-root `branches` shortcut is hidden through the repo-level git exclude the moment it is created. diff --git a/FEATURES-SPEC.md b/FEATURES-SPEC.md index 4aed4ba7e..424b4bc5a 100644 --- a/FEATURES-SPEC.md +++ b/FEATURES-SPEC.md @@ -12,7 +12,6 @@ happens while nobody is at the keyboard. | 1 | Install globally or run via `npx` | | 2 | `the-framework` spins up the dashboard and the daemon, in the foreground — Ctrl-C closes the dashboard and every agent with it | | 3 | Activate a repo from the dashboard (commits dirty state, creates `.the-framework/`, teaches `.gitignore`, registers it) | -| 4 | Auto-register every repo under a configured "repos directory" | | 5 | Onboarding checklist — each step derived from a real fact, not a click | | 6 | Prerequisite checks surfaced by the dashboard | | 7 | Per-agent preflight — probe the driver CLI before spending a branch | diff --git a/packages/the-framework/src/branch-links.SPEC.md b/packages/the-framework/src/branch-links.SPEC.md index 30d1f8f0f..6cd0acd24 100644 --- a/packages/the-framework/src/branch-links.SPEC.md +++ b/packages/the-framework/src/branch-links.SPEC.md @@ -5,6 +5,7 @@ Keeps every session checkout reachable by its branch name: new checkouts live in - A new checkout's folder is already named as its branch, so most need nothing extra. When a session renames its branch (most do, early on), the background pass adds a link under the new name — a rename costs a link, never moving a checkout under a running session. - Only the framework's own links are ever created, replaced, or removed: a user's own file, folder, or symlink at any of these paths is left alone. - A session on a branch whose name cannot be a folder name (old slashed names) simply gets no link. +- The `branches` shortcut at the repo root is hidden from git the moment it is made — it is the framework's, and left visible it would ride any sweeping commit onto a work branch. A user's own `branches` folder is never hidden. ## Before modifying/creating SPEC.md files diff --git a/packages/the-framework/src/branch-links.test.SPEC.md b/packages/the-framework/src/branch-links.test.SPEC.md index 9ccc4ec61..736f9659c 100644 --- a/packages/the-framework/src/branch-links.test.SPEC.md +++ b/packages/the-framework/src/branch-links.test.SPEC.md @@ -1,4 +1,4 @@ -Covers the branches view: a checkout still on its birth branch needs no link, a rename settles in one pass (old name dropped, new sibling link created), reclaimed checkouts lose their link, detached and slash-named branches get none, user files and foreign symlinks are never touched, the repo-root shortcut is created once without clobbering, and the daemon pass visits every project. +Covers the branches view: a checkout still on its birth branch needs no link, a rename settles in one pass (old name dropped, new sibling link created), reclaimed checkouts lose their link, detached and slash-named branches get none, user files and foreign symlinks are never touched, the repo-root shortcut is created once without clobbering and hidden from git at creation (while an occupied path stays visible), and the daemon pass visits every project. ## Before modifying/creating SPEC.md files diff --git a/packages/the-framework/src/branch-links.test.ts b/packages/the-framework/src/branch-links.test.ts index 6cd06b631..05e57a27d 100644 --- a/packages/the-framework/src/branch-links.test.ts +++ b/packages/the-framework/src/branch-links.test.ts @@ -66,14 +66,19 @@ test('only our own links are touched: user files and foreign symlinks stay', asy assert.equal(links.has(join(LINKS, 'tf-mine')), false, 'nothing was created over the user file') }) -test('the repo-root branches shortcut is created once, relative, and never clobbers', async () => { +test('the repo-root branches shortcut is created once, relative, hidden from git, and never clobbers (#1600)', async () => { const fresh = memFs() - await reconcileBranchLinks(CWD, { fs: fresh.fs, worktrees: async () => [] }) + const excluded: string[] = [] + const exclude = async (_repo: string, rule: string) => void excluded.push(rule) + await reconcileBranchLinks(CWD, { fs: fresh.fs, worktrees: async () => [], exclude }) assert.equal(fresh.links.get(ROOT_LINK), join(FRAMEWORK_DIR, BRANCHES_DIR)) + assert.deepEqual(excluded, ['/branches', '!/branches/'], 'the link is excluded the moment it is made') const taken = memFs({ files: [ROOT_LINK] }) - await reconcileBranchLinks(CWD, { fs: taken.fs, worktrees: async () => [] }) + excluded.length = 0 + await reconcileBranchLinks(CWD, { fs: taken.fs, worktrees: async () => [], exclude }) assert.equal(taken.links.has(ROOT_LINK), false, 'an occupied path is left alone') + assert.deepEqual(excluded, [], "a user's own entry is never hidden from their git status") }) test('the pass covers every registered project and a stopped pass does nothing', async () => { diff --git a/packages/the-framework/src/branch-links.ts b/packages/the-framework/src/branch-links.ts index 8287e6af8..42d0bb431 100644 --- a/packages/the-framework/src/branch-links.ts +++ b/packages/the-framework/src/branch-links.ts @@ -1,5 +1,6 @@ import { basename, join } from 'node:path' import { nodeGitRunner, type GitRunner } from './project.js' +import { excludeFromGit } from './git-exclude.js' import { FRAMEWORK_DIR, BRANCHES_DIR, worktreeDirEntries, currentBranch, type WorktreeDirEntry } from './store/index.js' import { isWorktreeDirName } from './branch-names.js' @@ -51,6 +52,8 @@ export interface BranchLinksDeps { worktrees?: (cwd: string) => Promise /** The branch a worktree is on (default {@link currentBranch}). */ branchOf?: (path: string) => Promise + /** Hide a repo-root entry from git (default {@link excludeFromGit} over `git`). */ + exclude?: (repo: string, rule: string) => Promise } /** @@ -67,6 +70,7 @@ export async function reconcileBranchLinks(cwd: string, deps: BranchLinksDeps = const fs = deps.fs ?? nodeLinksFs() const worktrees = deps.worktrees ?? worktreeDirEntries const branchOf = deps.branchOf ?? ((path: string) => currentBranch(path, git)) + const exclude = deps.exclude ?? ((repo: string, rule: string) => excludeFromGit(repo, rule, undefined, git)) const linksDir = join(cwd, FRAMEWORK_DIR, BRANCHES_DIR) /** Link name -> relative target, derived from what is actually checked out. */ @@ -101,10 +105,17 @@ export async function reconcileBranchLinks(cwd: string, deps: BranchLinksDeps = // The repo-root `branches` shortcut (#1580). Relative, so a checkout that moves keeps working; // created only when nothing sits at that path — a user's own `branches` file or dir is theirs. + // The link is framework state, so it is hidden from git the moment it is made (#1600): + // uncommitted at the root, it would ride any sweeping `git add -A` onto a code branch. The + // same exclude pair as the data checkout's `tickets` link: `/branches` hides root entries of + // that name, and `!/branches/` re-includes directories (a trailing slash never matches a + // symlink), so a user's own `branches` directory keeps committing while the link stays hidden. const rootLink = join(cwd, 'branches') if (!(await fs.lexists(rootLink))) { await fs.mkdir(linksDir) await fs.symlink(join(FRAMEWORK_DIR, BRANCHES_DIR), rootLink).catch(() => {}) + await exclude(cwd, '/branches').catch(() => {}) + await exclude(cwd, '!/branches/').catch(() => {}) } } diff --git a/packages/the-framework/src/daemon.SPEC.md b/packages/the-framework/src/daemon.SPEC.md index d8b25aa50..bc8e09854 100644 --- a/packages/the-framework/src/daemon.SPEC.md +++ b/packages/the-framework/src/daemon.SPEC.md @@ -6,7 +6,7 @@ The process behind the dashboard: it serves the UI, spawns agents, and runs the - The trade that buys: unattended work needs a window left open, the way any dev server does. The product's promise is spending idle quota while nobody is at the keyboard, and that still holds — but it is now visible and killable rather than invisible and persistent, which is the right direction for a tool that spends a subscription: nothing burns quota after you have closed it. - The dashboard is a projection of each project's on-disk event log, and steering flows back through an append-only control file — files are the seam, never a direct agent-to-dashboard connection. - Bound to localhost by default; binding to the network requires a generated shared token, because a process that spawns agents would otherwise be remote code execution for whoever finds the port. -- At boot it registers the home project (and, when opted in, every repo in the user's repos directory), marks agents a dead process left "running" as stopped, and starts the background services. It resumes nothing: Ctrl-C was deliberate. +- At boot it registers the home project, marks agents a dead process left "running" as stopped, and starts the background services. It resumes nothing: Ctrl-C was deliberate. Every other project joins through the dashboard's "Add project" — the one onboarding path, so a repo is always installed before an agent can touch it. - Shutdown is ordered: background services quiesce first, live agents are stopped, their archives committed, then the dashboard goes. Each step is waited out rather than merely started, so the archives being committed are the finished ones — the sweeps are off the repo before the agents are torn down, and the teardowns are done before their work is committed. ## Before modifying/creating SPEC.md files diff --git a/packages/the-framework/src/daemon.test.SPEC.md b/packages/the-framework/src/daemon.test.SPEC.md index 6c2f4aa8a..e165fef79 100644 --- a/packages/the-framework/src/daemon.test.SPEC.md +++ b/packages/the-framework/src/daemon.test.SPEC.md @@ -1,4 +1,4 @@ -Covers the daemon's lifecycle and dashboard-driven behavior: coming up on a fresh workspace and reporting where it bound, serving the dashboard until its signal aborts, event-log tailing, home-project and repos-directory registration rules, and starts over the dashboard — the JSON spec each child is handed, concurrent agents in their own worktrees, teardown reclaiming a checkout once its work is on the remote, steering through the control log, and the guard that refuses to re-exec a test file as an agent. +Covers the daemon's lifecycle and dashboard-driven behavior: coming up on a fresh workspace and reporting where it bound, serving the dashboard until its signal aborts, event-log tailing, home-project registration rules, and starts over the dashboard — the JSON spec each child is handed, concurrent agents in their own worktrees, teardown reclaiming a checkout once its work is on the remote, steering through the control log, and the guard that refuses to re-exec a test file as an agent. ## Before modifying/creating SPEC.md files diff --git a/packages/the-framework/src/daemon.test.ts b/packages/the-framework/src/daemon.test.ts index f2c529081..9600b470d 100644 --- a/packages/the-framework/src/daemon.test.ts +++ b/packages/the-framework/src/daemon.test.ts @@ -10,7 +10,6 @@ import { isProcessAlive, runDaemon, registerHomeProject, - registerReposDirectory, isNestedWithin, type DaemonState, type RunDaemonOptions, @@ -50,8 +49,9 @@ async function startDaemon(cwd: string, opts: RunDaemonOptions): Promise<{ done: import { listAgents } from './store/index.js' import { EVENTS_FILE, FRAMEWORK_DIR, addWorktree, worktreePath } from './store/index.js' import { controlPath } from './control.js' -import { projectId, listProjects, addProject, writePreferences } from './registry.js' +import { projectId, listProjects, addProject } from './registry.js' import { nodeGitRunner } from './project.js' +import { gitignorePath, frameworkGitignore } from './framework-gitignore.js' // The dashboard steers + starts over the daemon's in-process RPC mount (#405/#426), not the // retired per-read HTTP routes. Post to `/_rpc/` (same-origin) and return the unwrapped `ret`. @@ -75,9 +75,15 @@ const logEvent = (message: string): FrameworkEvent => ({ kind: 'log', message }) const line = (message: string): string => JSON.stringify(logEvent(message)) + '\n' const sleep = (ms: number): Promise => new Promise(resolve => setTimeout(resolve, ms)) +/** Fake an activated workspace: the install-written ignore file is the activation marker (#1600). */ +async function activate(cwd: string): Promise { + await mkdir(join(cwd, FRAMEWORK_DIR), { recursive: true }) + await writeFile(gitignorePath(cwd), frameworkGitignore()) +} + async function tmpWorkspace(): Promise { const cwd = await mkdtemp(join(tmpdir(), 'framework-daemon-')) - await mkdir(join(cwd, FRAMEWORK_DIR), { recursive: true }) + await activate(cwd) return cwd } @@ -204,7 +210,7 @@ test('a git project starts concurrent runs, each in its own worktree (#736)', as const git = nodeGitRunner() const ac = new AbortController() try { - await mkdir(join(cwd, FRAMEWORK_DIR), { recursive: true }) + await activate(cwd) await git(['init'], cwd) await git(['config', 'user.email', 't@t'], cwd) await git(['config', 'user.name', 't'], cwd) @@ -271,7 +277,7 @@ test('a run loses its worktree once its work is on the remote, whatever the run const git = nodeGitRunner() const ac = new AbortController() try { - await mkdir(join(cwd, FRAMEWORK_DIR), { recursive: true }) + await activate(cwd) await git(['init'], cwd) await git(['config', 'user.email', 't@t'], cwd) await git(['config', 'user.name', 't'], cwd) @@ -570,7 +576,7 @@ test('registerHomeProject skips a cwd nested inside an already-tracked project ( await addProject(parent, new Date().toISOString(), undefined, env) // A nested, activated subfolder (like packages/the-framework inside the repo). const nested = join(parent, 'packages', 'framework') - await mkdir(join(nested, FRAMEWORK_DIR), { recursive: true }) + await activate(nested) await registerHomeProject(nested, env) @@ -589,7 +595,7 @@ test('registerHomeProject still adds an activated cwd that is not nested (#647)' const home = await mkdtemp(join(tmpdir(), 'framework-home-')) const env = await configEnv(home) try { - await mkdir(join(home, FRAMEWORK_DIR), { recursive: true }) + await activate(home) await registerHomeProject(home, env) const projects = await listProjects(undefined, env) assert.deepEqual(projects.map(p => p.path), [home]) @@ -598,37 +604,3 @@ test('registerHomeProject still adds an activated cwd that is not nested (#647)' } }) -test('registerReposDirectory auto-adds the git repos when the opt-in is on (#1123)', async () => { - const root = await realpath(await mkdtemp(join(tmpdir(), 'framework-repos-'))) - const env = await configEnv(root) - try { - // Two git repos and one plain directory directly inside the repos dir. - await mkdir(join(root, 'app-a', '.git'), { recursive: true }) - await mkdir(join(root, 'app-b', '.git'), { recursive: true }) - await mkdir(join(root, 'not-a-repo'), { recursive: true }) - await writePreferences({ reposDirectory: root, reposDirectoryAutoGrant: true }, undefined, env) - - await registerReposDirectory(env) - - const projects = (await listProjects(undefined, env)).map(p => p.path).sort() - assert.deepEqual(projects, [join(root, 'app-a'), join(root, 'app-b')]) - } finally { - await rm(root, { recursive: true, force: true }) - } -}) - -test('registerReposDirectory adds nothing while the opt-in is off (#1123)', async () => { - const root = await realpath(await mkdtemp(join(tmpdir(), 'framework-repos-off-'))) - const env = await configEnv(root) - try { - await mkdir(join(root, 'app-a', '.git'), { recursive: true }) - // reposDirectory is set, but the auto-grant is not: the default must stay hands-off. - await writePreferences({ reposDirectory: root }, undefined, env) - - await registerReposDirectory(env) - - assert.deepEqual(await listProjects(undefined, env), []) - } finally { - await rm(root, { recursive: true, force: true }) - } -}) diff --git a/packages/the-framework/src/daemon.ts b/packages/the-framework/src/daemon.ts index a06a87dc7..415030ffe 100644 --- a/packages/the-framework/src/daemon.ts +++ b/packages/the-framework/src/daemon.ts @@ -1,5 +1,5 @@ import { mkdir } from 'node:fs/promises' -import { basename, join, relative, resolve, isAbsolute } from 'node:path' +import { basename, join, relative, isAbsolute } from 'node:path' import type { FrameworkEvent } from './events.js' import { FRAMEWORK_DIR, isPidAlive, reconcileOrphanedAgents } from './store/index.js' import { startDashboard, type Dashboard, type StartAgentOptions } from './dashboard/index.js' @@ -9,7 +9,6 @@ import { startBackgroundServices, type BackgroundServices } from './daemon-servi import { resolveDashboardBundle } from './dashboard/bundle.js' import { isActivated } from './project.js' import { addProject, ensureDaemonToken, listProjects, nodeRegistryFs, readPreferences, registryPreferencesStore, type Preferences } from './registry.js' -import { listReposInDirectory } from './repos-directory.js' import { registryDiscordCredentialsStore } from './discord-credentials-store.js' import { JsonlTailer } from './jsonl-tail.js' import { isLoopbackHost } from './loopback-host.js' @@ -86,25 +85,6 @@ export async function registerHomeProject(cwd: string, env: NodeJS.ProcessEnv = await addProject(cwd, new Date().toISOString(), undefined, env).catch(() => {}) } -/** - * Auto-register every git repo in the user's repos directory (#1123), when they turned the opt-in on. - * - * Off unless `reposDirectoryAutoGrant` is set: it grants the app filesystem access to a whole - * directory of repos at once, so it stays an explicit choice, and the blast radius is contained to - * that one directory. Idempotent (addProject dedupes by path); logs one line per newly added repo. - * Best-effort, so a bad path never blocks the daemon coming up. - */ -export async function registerReposDirectory(env: NodeJS.ProcessEnv = process.env): Promise { - const { reposDirectory, reposDirectoryAutoGrant } = await readPreferences(undefined, env).catch((): Preferences => ({})) - if (!reposDirectoryAutoGrant || !reposDirectory) return - const known = new Set((await listProjects(undefined, env).catch(() => [])).map(p => resolve(p.path))) - for (const repo of await listReposInDirectory(reposDirectory).catch(() => [])) { - if (known.has(resolve(repo))) continue - await addProject(repo, new Date().toISOString(), undefined, env).catch(() => {}) - console.log(`[framework] auto-added repo ${basename(repo)} from ${reposDirectory}`) - } -} - /** True when a process with this id is still running (best-effort, signal 0). The store's * {@link isPidAlive} under the daemon's historical public name -- the two were byte-identical. */ export { isPidAlive as isProcessAlive } from './store/index.js' @@ -166,9 +146,6 @@ export async function runDaemon(cwd: string, opts: RunDaemonOptions = {}): Promi // Multi-project (#392): make sure an activated home repo shows up in the Projects list. await registerHomeProject(cwd, env) - // Repos directory (#1123): when the opt-in is on, add every git repo in the user's repos dir. - await registerReposDirectory(env) - // Crash recovery (#642): a fresh daemon drives no in-flight run, so any run a dead // process left marked `running` is orphaned — it would show as active forever with a // no-op Stop. Reconcile them to `stopped` across every registered project at boot. diff --git a/packages/the-framework/src/project.SPEC.md b/packages/the-framework/src/project.SPEC.md index c57008150..ffbdfdf6f 100644 --- a/packages/the-framework/src/project.SPEC.md +++ b/packages/the-framework/src/project.SPEC.md @@ -2,7 +2,7 @@ Read-only project helpers: whether a repo has The Framework installed, what its ## TLDR -- A repo counts as activated when the framework's marker directory exists; creating it is a separate concern. +- A repo counts as activated when the ignore file the install writes exists — the file that keeps the framework's transient state off the repo's branches. That way a repo can never look activated while it still lacks the one protection activation is about; writing it is a separate concern. - Detection signals are the dependency names from the project's package manifest; a from-scratch project simply has none. - The file crawl lists everything git sees (tracked and untracked, honoring ignores) and yields nothing rather than failing. - Git operations get one of three time budgets — read, local write, or network/whole-checkout — because killing a slow push or checkout mid-flight can corrupt real work, while a hung read must not hold the daemon for minutes. diff --git a/packages/the-framework/src/project.test.ts b/packages/the-framework/src/project.test.ts index 4732830e3..01e8ea4bd 100644 --- a/packages/the-framework/src/project.test.ts +++ b/packages/the-framework/src/project.test.ts @@ -1,39 +1,33 @@ import { strict as assert } from 'node:assert' import { test } from 'node:test' -import { join } from 'node:path' -import { THE_FRAMEWORK_DIR } from './framework-dir.js' import { crawlRepoFiles, gitTimeoutMs, isActivated, - theFrameworkDir, GIT_READ_TIMEOUT_MS, GIT_SLOW_TIMEOUT_MS, GIT_WRITE_TIMEOUT_MS, type GitRunner, type ProjectFs, } from './project.js' +import { gitignorePath } from './framework-gitignore.js' const CWD = '/proj' -/** A {@link ProjectFs} that reports exactly one set of paths as directories. */ -function fakeFs(dirs: string[]): ProjectFs { +/** A {@link ProjectFs} that reports exactly one set of paths as existing files. */ +function fakeFs(files: string[]): ProjectFs { return { - async isDirectory(path) { - return dirs.includes(path) + async exists(path) { + return files.includes(path) }, } } -test('theFrameworkDir joins cwd + .the-framework', () => { - assert.equal(theFrameworkDir(CWD), join(CWD, THE_FRAMEWORK_DIR)) +test('isActivated is true when the install-written .the-framework/.gitignore exists (#1600)', async () => { + assert.equal(await isActivated(CWD, fakeFs([gitignorePath(CWD)])), true) }) -test('isActivated is true when .the-framework/ is a directory', async () => { - assert.equal(await isActivated(CWD, fakeFs([join(CWD, THE_FRAMEWORK_DIR)])), true) -}) - -test('isActivated is false when the marker dir is absent', async () => { +test('isActivated is false without the ignore file — a bare .the-framework/ dir is not activation (#1600)', async () => { assert.equal(await isActivated(CWD, fakeFs([])), false) }) diff --git a/packages/the-framework/src/project.ts b/packages/the-framework/src/project.ts index 4d76adad9..7e3f6168f 100644 --- a/packages/the-framework/src/project.ts +++ b/packages/the-framework/src/project.ts @@ -1,41 +1,37 @@ import { cliRunner, type CliRunner } from './cli-exec.js' import { readFileSync } from 'node:fs' -import { join } from 'node:path' import { nodeFs } from './node-fs.js' -import { THE_FRAMEWORK_DIR } from './framework-dir.js' +import { gitignorePath } from './framework-gitignore.js' /** - * Project-level repo helpers (#380): the `.the-framework/` activation marker - * check, a `git ls-files` crawl, and the project's detection signals. Read-only - * building blocks for the sidebars (#314); activation/install (creating the dir, - * the install commit) is a separate, deferred concern. + * Project-level repo helpers (#380): the activation marker check, a + * `git ls-files` crawl, and the project's detection signals. Read-only + * building blocks for the sidebars (#314); activation/install (writing the + * marker, the install commit) is a separate, deferred concern. */ -/** The `.the-framework/` path under a project root. */ -export function theFrameworkDir(cwd: string): string { - return join(cwd, THE_FRAMEWORK_DIR) -} - /** Minimal fs seam so activation is unit-testable without touching disk. */ export interface ProjectFs { - /** True when `path` exists AND is a directory. */ - isDirectory(path: string): Promise + /** True when `path` exists AND is a file. */ + exists(path: string): Promise } /** A {@link ProjectFs} backed by `node:fs/promises`. See {@link nodeFs}. */ export function nodeProjectFs(): ProjectFs { - const { isDirectory } = nodeFs() - return { isDirectory } + const { exists } = nodeFs() + return { exists } } /** - * A repo is "activated"/installed for The Framework when it has a - * `.the-framework/` directory (#314: the dir is the activation marker). - * Read-only check; creating the dir + the install commit is a separate, - * deferred concern. + * A repo is "activated"/installed for The Framework when it has the + * `.the-framework/.gitignore` install writes — the same marker install's own + * no-op check reads (#1600), so a `.the-framework/` directory something else + * created can never read as activated while the repo still lacks the ignore + * file that keeps framework state off its branches. Read-only check; writing + * the marker + the install commit is a separate, deferred concern. */ export async function isActivated(cwd: string, fs: ProjectFs = nodeProjectFs()): Promise { - return fs.isDirectory(theFrameworkDir(cwd)) + return fs.exists(gitignorePath(cwd)) } /** Runs `git` in `cwd` and resolves stdout. Injectable so the crawl is testable. */ diff --git a/packages/the-framework/src/registry.test.ts b/packages/the-framework/src/registry.test.ts index a0b93708c..78c45582d 100644 --- a/packages/the-framework/src/registry.test.ts +++ b/packages/the-framework/src/registry.test.ts @@ -207,7 +207,6 @@ test('every boolean preference survives a save; the sanitizer cannot silently dr autoPm: true, bridge: true, onboardingDismissed: true, - reposDirectoryAutoGrant: true, } const fs = memFs() await writePreferences(allOn, fs, ENV) @@ -238,18 +237,6 @@ test('sanitizePreferences reads only the current spellings', async () => { assert.deepEqual(await stored({ driver: 'codex', agent: 'gpt-9000' }), { driver: 'codex' }) }) -test('sanitizePreferences keeps an absolute reposDirectory and drops junk (#1123)', async () => { - // Another string preference the boolean-only loop would eat: kept only as a non-empty absolute - // path, so a relative or blank value never lands in the file. - const fs = memFs() - await writePreferences({ reposDirectory: '/home/u/repos' }, fs, ENV) - assert.deepEqual(await readPreferences(fs, ENV), { reposDirectory: '/home/u/repos' }) - await writePreferences({ reposDirectory: 'relative/repos' }, fs, ENV) - assert.deepEqual(await readPreferences(fs, ENV), {}) - await writePreferences({ reposDirectory: ' ' }, fs, ENV) - assert.deepEqual(await readPreferences(fs, ENV), {}) -}) - test('sanitizePreferences keeps the routine opt-out list, trimmed and deduplicated (#1209)', async () => { // A list preference, so like the string ones the boolean-only loop would eat it whole. Junk // entries are dropped one by one rather than taking the list with them: losing the list would @@ -264,13 +251,6 @@ test('sanitizePreferences keeps the routine opt-out list, trimmed and deduplicat assert.deepEqual(await readPreferences(fs, ENV), {}) }) -test('reposDirectoryAutoGrant is a boolean preference, off by default (#1123)', async () => { - const fs = memFs() - assert.equal((await readPreferences(fs, ENV)).reposDirectoryAutoGrant, undefined) // absent = off - await writePreferences({ reposDirectory: '/home/u/repos', reposDirectoryAutoGrant: true }, fs, ENV) - assert.deepEqual(await readPreferences(fs, ENV), { reposDirectory: '/home/u/repos', reposDirectoryAutoGrant: true }) -}) - test('patchPreferences merges only the keys it is given (#1148)', async () => { // The dashboard used to send its whole cached object, so a tab that had been open since before // someone else's change wrote the old value back over it. A patch touches only what it names. diff --git a/packages/the-framework/src/registry.ts b/packages/the-framework/src/registry.ts index 08ca6f6cf..5ce5bc288 100644 --- a/packages/the-framework/src/registry.ts +++ b/packages/the-framework/src/registry.ts @@ -1,6 +1,6 @@ import { isAgentLocation, type AgentLocation } from './agent-location.js' import { isHandoffLevel, type HandoffLevel } from './handoff-level.js' -import { basename, dirname, isAbsolute, join, resolve } from 'node:path' +import { basename, dirname, join, resolve } from 'node:path' import { randomBytes } from 'node:crypto' import { isDriverName } from './driver-names.js' import { nodeFs } from './node-fs.js' @@ -155,18 +155,6 @@ export interface Preferences { * the same checklist stays available on the settings page. */ onboardingDismissed?: boolean - /** - * Absolute path to the directory the user keeps their repos in (#1123): the default the - * create-project flow (#1121) offers, and the root {@link reposDirectoryAutoGrant} scans. - * Absent = no default. Kept only as a non-empty absolute path. - */ - reposDirectory?: string - /** - * Auto-add and grant every git repo directly inside {@link reposDirectory} on daemon boot (#1123). - * **Absent = off**: it hands the app filesystem access to a whole directory of repos at once, so it - * is an explicit opt-in, and the blast radius is contained to that one directory. - */ - reposDirectoryAutoGrant?: boolean } // The bounds the browser's controls and this file's sanitizer both need live in the leaf @@ -335,7 +323,6 @@ const BOOLEAN_PREFERENCES: Record = { autoPm: true, bridge: true, onboardingDismissed: true, - reposDirectoryAutoGrant: true, } const PREFERENCE_KEYS = Object.keys(BOOLEAN_PREFERENCES) as BooleanPreferenceKey[] @@ -382,10 +369,6 @@ function sanitizePreferences(value: unknown): Preferences { const offset = input['autoSpendOffset'] if (typeof offset === 'number' && Number.isFinite(offset)) preferences.autoSpendOffset = Math.round(Math.min(Math.max(offset, -MAX_SPEND_OFFSET), MAX_SPEND_OFFSET)) - // `reposDirectory` (#1123) is a string, so like `target` the boolean-only loop would eat it. Kept - // only as a non-empty absolute path; a relative or junk value is dropped rather than persisted. - const reposDir = typeof input['reposDirectory'] === 'string' ? input['reposDirectory'].trim() : '' - if (reposDir && isAbsolute(reposDir)) preferences.reposDirectory = reposDir const customPresets = sanitizeCustomPresets(input['customPresets']) if (customPresets.length) preferences.customPresets = customPresets // `autoPmOptOut` (#1209) is a list of routine names, kept as free-form strings rather than diff --git a/packages/the-framework/src/repos-directory.SPEC.md b/packages/the-framework/src/repos-directory.SPEC.md deleted file mode 100644 index b56676b43..000000000 --- a/packages/the-framework/src/repos-directory.SPEC.md +++ /dev/null @@ -1,5 +0,0 @@ -Finds the git repositories sitting directly inside the directory the user keeps their repos in, so the daemon can auto-register them when that opt-in is on — one level deep only, keeping the grant's blast radius to that one directory. - -## Before modifying/creating SPEC.md files - -You must always read and respect https://raw.githubusercontent.com/brillout/sdd/refs/heads/main/sdd.md diff --git a/packages/the-framework/src/repos-directory.test.SPEC.md b/packages/the-framework/src/repos-directory.test.SPEC.md deleted file mode 100644 index bdc439dcd..000000000 --- a/packages/the-framework/src/repos-directory.test.SPEC.md +++ /dev/null @@ -1,5 +0,0 @@ -Tests that the scan finds only immediate child directories that are git repos (normal clones and worktrees alike) and quietly yields nothing for a missing directory. - -## Before modifying/creating SPEC.md files - -You must always read and respect https://raw.githubusercontent.com/brillout/sdd/refs/heads/main/sdd.md diff --git a/packages/the-framework/src/repos-directory.test.ts b/packages/the-framework/src/repos-directory.test.ts deleted file mode 100644 index 07bb6c371..000000000 --- a/packages/the-framework/src/repos-directory.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { strict as assert } from 'node:assert' -import { test } from 'node:test' -import { join } from 'node:path' -import { listReposInDirectory, type ReposDirectoryFs } from './repos-directory.js' - -/** - * A fake fs modelling a directory tree as a set of file paths and a set of directory paths, so the - * scan is tested without touching disk. A `.git` listed under `files` models a worktree/submodule - * pointer file; one listed under `dirs` models a normal clone. - */ -function fakeFs(tree: { dirs?: string[]; files?: string[] }): ReposDirectoryFs { - const dirs = new Set(tree.dirs ?? []) - const files = new Set(tree.files ?? []) - return { - async readdir(path) { - const prefix = path.endsWith('/') ? path : path + '/' - const names = new Set() - for (const entry of [...dirs, ...files]) { - if (!entry.startsWith(prefix)) continue - const rest = entry.slice(prefix.length) - if (!rest.includes('/')) names.add(rest) - } - return [...names] - }, - async isDirectory(path) { - return dirs.has(path) - }, - async exists(path) { - return files.has(path) - }, - } -} - -test('listReposInDirectory returns only the child dirs holding a .git (#1123)', async () => { - const root = '/home/u/repos' - const fs = fakeFs({ - dirs: [ - root, - join(root, 'app-a'), - join(root, 'app-a', '.git'), // normal clone: .git is a directory - join(root, 'app-b'), - join(root, 'not-a-repo'), // a plain directory, no .git - ], - files: [ - join(root, 'app-b', '.git'), // worktree/submodule: .git is a file - join(root, 'README.md'), // a loose file, not a directory - ], - }) - assert.deepEqual(await listReposInDirectory(root, fs), [join(root, 'app-a'), join(root, 'app-b')]) -}) - -test('listReposInDirectory yields [] for a missing directory (#1123)', async () => { - assert.deepEqual(await listReposInDirectory('/nope', fakeFs({})), []) -}) diff --git a/packages/the-framework/src/repos-directory.ts b/packages/the-framework/src/repos-directory.ts deleted file mode 100644 index 5500daa01..000000000 --- a/packages/the-framework/src/repos-directory.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { join } from 'node:path' -import { nodeFs, type NodeFs } from './node-fs.js' - -/** - * The repos-directory helper (#1123): find the git repos directly inside a directory the user - * pointed The Framework at, so the daemon can auto-register them when the opt-in is on. - * - * The fs is a seam so this is unit-testable without touching disk, matching the rest of the package. - */ - -/** The narrow fs the scan needs: list a directory and test its entries. {@link nodeFs} satisfies it. */ -export type ReposDirectoryFs = Pick - -/** - * The immediate subdirectories of `dir` that are git repos: a child directory holding a `.git` - * (a directory in a normal clone, a file in a worktree or submodule). Returns absolute paths, sorted. - * - * Only one level deep on purpose (#1123): the auto-grant's blast radius is "this directory of repos", - * not the whole tree beneath it. A missing `dir` yields `[]` (readdir already swallows that), so it - * never throws on boot. - */ -export async function listReposInDirectory(dir: string, fs: ReposDirectoryFs = nodeFs()): Promise { - const repos: string[] = [] - for (const name of (await fs.readdir(dir)).sort()) { - const child = join(dir, name) - if (!(await fs.isDirectory(child))) continue - const git = join(child, '.git') - if ((await fs.exists(git)) || (await fs.isDirectory(git))) repos.push(child) - } - return repos -}