diff options
| author | user <user@clank> | 2026-08-12 21:55:12 +0200 |
|---|---|---|
| committer | user <user@clank> | 2026-08-12 21:55:12 +0200 |
| commit | 72441afa75c058e4b1454108d962f2bebaee941b (patch) | |
| tree | d668f1f61d8cbf19127013724db50cf680aa59c9 /cli/src | |
| parent | docs: agree the sl0ppi CLI design (diff) | |
| download | sl0ppi-72441afa75c058e4b1454108d962f2bebaee941b.tar.gz sl0ppi-72441afa75c058e4b1454108d962f2bebaee941b.tar.xz sl0ppi-72441afa75c058e4b1454108d962f2bebaee941b.zip | |
cli: the idempotency core (discovery, tab identity, plan diff)
Slice 1 of docs/cli-design.md: the only part with logic worth testing hard.
Pure functions, no subprocesses, so the tests hit them directly.
purpose.ts tab identity as a zellij tab purpose carrying the project's
root-relative path
discover.ts depth-1 scan for .sl0ppi/ markers
plan.ts discovered projects vs list-tabs -> {create, close, keep, foreign}
Identity carries the *path* because `list-panes --json` exposes no cwd (checked
against a live session, not assumed), so there is otherwise no way back from a
tab to a directory on disk. Slashes survive the fork's sanitiser, so nested
paths encode fine.
Two safety properties, both fuzzed over 5000 random names:
- a purpose never exceeds 64 chars and always survives re-sanitising, so what
we generate is what the terminal stores
- projectRelPath() reconstructs the original path exactly or returns null --
never a different path. Wrong answers close the wrong tab; refusing only
leaves a stale one.
The fuzz caught a real bug in the second property. The first implementation
detected lossy encodings by re-encoding and comparing, which silently failed:
`project:<47 a's>.<hash8>` is itself 64 legal characters, so it re-encodes to
itself, the check passed, and a bogus path was "reconstructed" -- which would
have closed a live tab. Lossy purposes now carry a structural marker (a leading
dot, which no discovered path can have) instead of relying on a comparison.
Plus the mount guard from the design: when the ROOT itself is missing, close
nothing. An unmounted filesystem is indistinguishable from mass deletion, and
one of those two readings eats your whole session.
31 unit tests + 2 fuzz properties, 10178 assertions, green.
Diffstat (limited to 'cli/src')
| -rw-r--r-- | cli/src/core/discover.ts | 79 | ||||
| -rw-r--r-- | cli/src/core/plan.ts | 119 | ||||
| -rw-r--r-- | cli/src/core/purpose.ts | 94 |
3 files changed, 292 insertions, 0 deletions
diff --git a/cli/src/core/discover.ts b/cli/src/core/discover.ts new file mode 100644 index 0000000..3511b3e --- /dev/null +++ b/cli/src/core/discover.ts @@ -0,0 +1,79 @@ +/** + * Project discovery. + * + * A project is a directory under the root containing a `.sl0ppi/` marker. + * Depth-1 by default: recursively scanning ~/dev is slow and surprising, and + * an explicit `sl0ppi open path/to/nested` covers the rest. + */ + +import { readdirSync, statSync } from "node:fs"; +import { join } from "node:path"; + +export const MARKER_DIR = ".sl0ppi"; +export const LAYOUT_FILE = "layout.kdl"; + +export interface DiscoverOptions { + /** How many directory levels below the root to search. Default 1. */ + depth?: number; +} + +/** + * Paths of discovered projects, relative to `root`, sorted alphabetically. + * + * Unreadable directories are skipped rather than fatal: one bad-permission + * directory in ~/dev must not stop the other eleven projects from opening. + */ +export function discoverProjects(root: string, options: DiscoverOptions = {}): string[] { + const depth = options.depth ?? 1; + const found: string[] = []; + walk(root, "", depth, found); + return found.sort(); +} + +function walk(root: string, rel: string, depthLeft: number, found: string[]): void { + if (depthLeft <= 0) return; + + let entries; + try { + entries = readdirSync(join(root, rel), { withFileTypes: true }); + } catch { + return; // unreadable: skip, do not throw + } + + for (const entry of entries) { + // Skip dotfiles: `.git` and friends are never projects, and this also + // stops us descending into the marker directory itself. + if (entry.name.startsWith(".")) continue; + + const childRel = rel ? `${rel}/${entry.name}` : entry.name; + const childAbs = join(root, childRel); + + // Follow symlinked directories (a worktree or a symlinked checkout is a + // perfectly ordinary way to lay out ~/dev), but never fail on a broken one. + let isDir = entry.isDirectory(); + if (!isDir && entry.isSymbolicLink()) { + try { + isDir = statSync(childAbs).isDirectory(); + } catch { + continue; // dangling symlink + } + } + if (!isDir) continue; + + if (isProjectDir(childAbs)) { + found.push(childRel); + continue; // a project is a leaf; do not nest projects inside projects + } + + walk(root, childRel, depthLeft - 1, found); + } +} + +/** A directory is a project when it carries the `.sl0ppi/` marker directory. */ +export function isProjectDir(absPath: string): boolean { + try { + return statSync(join(absPath, MARKER_DIR)).isDirectory(); + } catch { + return false; + } +} diff --git a/cli/src/core/plan.ts b/cli/src/core/plan.ts new file mode 100644 index 0000000..7c947d2 --- /dev/null +++ b/cli/src/core/plan.ts @@ -0,0 +1,119 @@ +/** + * The idempotency core. + * + * `sl0ppi up` must be a no-op the second time you run it. Everything else in + * the CLI is plumbing; if this diff is wrong the tool is worse than useless. + * So it is a pure function over data — no filesystem, no subprocesses — and the + * tests hammer it directly. + */ + +import { isProjectPurpose, projectPurpose, projectRelPath } from "./purpose.ts"; + +export interface TabInfo { + /** zellij tab position, which is what `go-to-tab` wants. */ + position: number; + name: string; + purpose?: string; +} + +export interface PlanInput { + /** Absolute path of the root whose session this is. */ + root: string; + /** Whether the root itself is present (see the mount guard below). */ + rootExists: boolean; + /** Discovered projects, as paths relative to the root. */ + projects: string[]; + /** Tabs currently in the session. */ + tabs: TabInfo[]; + /** Injected so the planner stays pure and testable. */ + pathExists: (absPath: string) => boolean; +} + +export interface CreateAction { + relPath: string; + purpose: string; +} + +export interface Plan { + create: CreateAction[]; + close: TabInfo[]; + /** Project tabs that already match a discovered project. */ + keep: TabInfo[]; + /** Tabs sl0ppi does not manage (no `project:` purpose). Never touched. */ + foreign: TabInfo[]; +} + +export function planTabs(input: PlanInput): Plan { + const { root, rootExists, projects, tabs, pathExists } = input; + + // Expected state: one tab per discovered project, keyed by purpose. + const expected = new Map<string, string>(); // purpose -> relPath + for (const relPath of projects) expected.set(projectPurpose(relPath), relPath); + + const create: CreateAction[] = []; + const close: TabInfo[] = []; + const keep: TabInfo[] = []; + const foreign: TabInfo[] = []; + const seen = new Set<string>(); + + for (const tab of tabs) { + if (!isProjectPurpose(tab.purpose)) { + // Someone else's tab (a scratch tab the user opened by hand). Not ours. + foreign.push(tab); + continue; + } + const purpose = tab.purpose as string; + + if (expected.has(purpose)) { + keep.push(tab); + seen.add(purpose); + continue; + } + + // Not a project we discovered. Either its directory is gone, or it is a + // project outside the depth-1 scan that was opened explicitly. Only the + // first justifies closing a tab, and closing is destructive, so every + // uncertain case keeps the tab. + if (!rootExists) { + // The ROOT is missing, not the project. That is an unmounted filesystem, + // not twelve deleted projects: closing here would eat the whole session. + keep.push(tab); + continue; + } + + const relPath = projectRelPath(purpose); + if (relPath === null) { + // Purpose does not reconstruct (truncated/hashed), so we cannot check + // the directory. Refuse to guess. + keep.push(tab); + continue; + } + + if (pathExists(joinPath(root, relPath))) { + // Directory is there; it simply was not discovered (nested, or its + // .sl0ppi/ marker was removed). Leave it alone. + keep.push(tab); + continue; + } + + close.push(tab); + } + + // Alphabetical, per the agreed MVP ordering. + for (const relPath of [...projects].sort()) { + const purpose = projectPurpose(relPath); + if (!seen.has(purpose)) create.push({ relPath, purpose }); + } + + return { create, close, keep, foreign }; +} + +/** Trivial join; avoids pulling node:path into a pure module. */ +function joinPath(root: string, rel: string): string { + return root.endsWith("/") ? `${root}${rel}` : `${root}/${rel}`; +} + +/** True when applying this plan would change nothing. */ +export function isNoOp(plan: Plan): boolean { + return plan.create.length === 0 && plan.close.length === 0; +} diff --git a/cli/src/core/purpose.ts b/cli/src/core/purpose.ts new file mode 100644 index 0000000..728f27b --- /dev/null +++ b/cli/src/core/purpose.ts @@ -0,0 +1,94 @@ +/** + * Tab identity. + * + * A project tab is keyed by its zellij tab `purpose`, because that is the only + * field the fork guarantees is (a) queryable via `list-tabs --json`, (b) not + * overridable in-band by whatever a pane is displaying, and (c) restored + * through the layout after a session is resurrected. Tab *names* are UI strings + * a human renames; they are not identity. + * + * The purpose carries the project's path RELATIVE TO THE ROOT, because + * `list-panes --json` exposes no `cwd` (verified against a live session), so + * there is no other way to get from a tab back to a directory on disk. Slashes + * survive the fork's sanitiser, so nested paths work. + * + * The fork's sanitiser: keep [A-Za-z0-9_.:/-], then truncate to 64 chars. + * See sanitize_pane_purpose() in zellij-utils/src/input/layout.rs. + */ + +import { createHash } from "node:crypto"; + +/** MAX_PANE_PURPOSE in the fork. */ +export const PURPOSE_MAX = 64; +export const PROJECT_PREFIX = "project:"; + +/** Everything the fork's sanitiser strips. */ +const DISALLOWED = /[^A-Za-z0-9_.:/-]/g; + +/** Reproduces sanitize_pane_purpose(): filter, then truncate. */ +export function sanitizePurpose(raw: string): string { + return raw.replace(DISALLOWED, "").slice(0, PURPOSE_MAX); +} + +function hash8(value: string): string { + return createHash("sha256").update(value).digest("hex").slice(0, 8); +} + +/** + * Marks a purpose whose path was lossy and must never be reconstructed. + * + * A leading dot is safe as a sentinel because discovery skips dot-entries, so + * no real relative path we encode can begin with one. + * + * This has to be *structural*. The first implementation just re-encoded the + * candidate and compared, which silently failed: `project:<47 a's>.<hash8>` is + * itself 64 chars of legal characters, so it re-encodes to itself, the check + * passed, and a bogus path was "reconstructed" — which would have closed a + * live tab. Caught by `refuses to reconstruct a truncated/hashed purpose`. + */ +const LOSSY_MARK = "."; + +/** + * The purpose for a project at `relPath` (relative to its root). + * + * Normally this is simply `project:<relPath>`, which round-trips back to a + * path. When the path would not survive sanitising or does not fit, we fall + * back to a marked, truncated form plus a hash: still *unique*, deliberately + * not *reconstructable*, so `projectRelPath()` refuses it and we never + * auto-close a tab we cannot verify. + */ +export function projectPurpose(relPath: string): string { + const cleaned = sanitizePurpose(relPath); + const exact = + cleaned === relPath && + cleaned.length > 0 && + !cleaned.startsWith(LOSSY_MARK) && + PROJECT_PREFIX.length + cleaned.length <= PURPOSE_MAX; + if (exact) return PROJECT_PREFIX + cleaned; + + // sentinel(1) + head + "."(1) + hash8(8) + const budget = PURPOSE_MAX - PROJECT_PREFIX.length - 10; + const head = cleaned.slice(0, budget).replace(/[./-]+$/, "") || "project"; + return `${PROJECT_PREFIX}${LOSSY_MARK}${head}.${hash8(relPath)}`; +} + +export function isProjectPurpose(purpose: string | undefined | null): boolean { + return typeof purpose === "string" && purpose.startsWith(PROJECT_PREFIX); +} + +/** + * The relative path a purpose encodes, or null when it cannot be reconstructed + * exactly. + * + * Null is not a failure, it is a refusal. A lossy purpose is marked at encode + * time and rejected here, so callers cannot mistake `project:.head.a1b2c3d4` + * for a directory literally named `head.a1b2c3d4` and close a tab over it. The + * re-encode comparison stays as a second line of defence. + */ +export function projectRelPath(purpose: string): string | null { + if (!isProjectPurpose(purpose)) return null; + const candidate = purpose.slice(PROJECT_PREFIX.length); + if (candidate.length === 0) return null; + if (candidate.startsWith(LOSSY_MARK)) return null; + return projectPurpose(candidate) === purpose ? candidate : null; +} |
