aboutsummaryrefslogtreecommitdiffstats
path: root/cli/src
diff options
context:
space:
mode:
authoruser <user@clank>2026-08-12 21:55:12 +0200
committeruser <user@clank>2026-08-12 21:55:12 +0200
commit72441afa75c058e4b1454108d962f2bebaee941b (patch)
treed668f1f61d8cbf19127013724db50cf680aa59c9 /cli/src
parentdocs: agree the sl0ppi CLI design (diff)
downloadsl0ppi-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.ts79
-rw-r--r--cli/src/core/plan.ts119
-rw-r--r--cli/src/core/purpose.ts94
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;
+}