diff options
| author | user <user@clank> | 2026-08-12 22:24:55 +0200 |
|---|---|---|
| committer | user <user@clank> | 2026-08-12 22:24:55 +0200 |
| commit | 2cd247828d863cdc779c7178c988152835096ca2 (patch) | |
| tree | 867b63f58057e81ac8ab4d242e73b733b8372c9e /cli/src/core/root.ts | |
| parent | docs(cli): record the fork gap the design turned up (diff) | |
| download | sl0ppi-2cd247828d863cdc779c7178c988152835096ca2.tar.gz sl0ppi-2cd247828d863cdc779c7178c988152835096ca2.tar.xz sl0ppi-2cd247828d863cdc779c7178c988152835096ca2.zip | |
cli: up, ls, open, close
Slice 2 of docs/cli-design.md. The commands read the world (discovery +
list-tabs), compute a plan, then apply it -- never mutating while walking, which
is how `up` stays idempotent.
sl0ppij.ts process wrapper; every piece of state comes back out of
list-tabs/list-panes, so there is nothing to desync
layout.ts composes the tab node around a project's fragment
root.ts root resolution, session naming, the root registry
workspace.ts up / ls / open / close
index.ts dispatch + the fail-open wrapper
Four things this turned up that reading the code would not have:
- `attach --create-background` fails with "There is no active session!" when
the caller is itself inside a session -- which sl0ppi almost always is,
being run from the user's daily-driver terminal. Session creation now runs
with the SL0PPIJ*/ZELLIJ* markers stripped: we are creating a new detached
session, not nesting.
- falling open by passing our own argv to sl0ppij just produces "unrecognized
subcommand: up" and still leaves the user without a terminal. Our verbs and
flags are stripped first, so what is handed over is something sl0ppij
actually understands.
- attaching panics in the client when stdin is not a tty, so `up` from a
script or CI step died. It now reports the session is ready and exits.
- a start_suspended pane reports `is_held`, not `exited`. Conflating them made
`ls` claim every agent was running when none had started. Plugin panes (the
status bar, the link handler) also inflated pane counts and are now excluded
as chrome.
Index conventions were verified against a live session rather than inferred,
because guessing closes the wrong tab: list-tabs `position` and
`close-tab --tab-id` are 0-based, `go-to-tab` is 1-based, and closing renumbers
every tab after it, so batched closes run in descending position order.
Verified end to end against a real session (three projects, one shipping a
custom fragment): up creates the session with all tabs and their purposes; up
again reports "up to date" with tabs unchanged; ls reports panes and
agent:suspended correctly; open creates-and-focuses and resolves unique
prefixes; close removes a tab; deleting a project auto-closes its tab; and with
the root itself missing, nothing is closed at all.
64 tests green.
Diffstat (limited to 'cli/src/core/root.ts')
| -rw-r--r-- | cli/src/core/root.ts | 116 |
1 files changed, 116 insertions, 0 deletions
diff --git a/cli/src/core/root.ts b/cli/src/core/root.ts new file mode 100644 index 0000000..8e91d7f --- /dev/null +++ b/cli/src/core/root.ts @@ -0,0 +1,116 @@ +/** + * Roots and session names. + * + * One root directory = one sl0ppij session. The session name is derived from + * the root's basename so that typing `sl0ppi` in ~/dev always lands in the same + * session instead of minting another randomly-named one. + * + * The registry (session name -> root path) exists because a tab purpose stores + * a path *relative to the root*, and `list-panes --json` exposes no cwd, so + * without it there is no way back from a session to a directory. It is a + * rebuildable index, not a source of truth: delete it and the next `up --root` + * writes it again. It doubles as the record of which sessions are ours, which + * is how sl0ppi avoids touching sessions it did not create. + */ + +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { basename, dirname, join, resolve } from "node:path"; +import { homedir } from "node:os"; +import { createHash } from "node:crypto"; +import { isProjectDir } from "./discover.ts"; + +export function configDir(env: NodeJS.ProcessEnv = process.env): string { + return env.SL0PPI_CONFIG_DIR ?? join(env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "sl0ppi"); +} + +export function registryPath(env?: NodeJS.ProcessEnv): string { + return join(configDir(env), "roots.json"); +} + +export type Registry = Record<string, string>; + +export function loadRegistry(env?: NodeJS.ProcessEnv): Registry { + const path = registryPath(env); + try { + const parsed = JSON.parse(readFileSync(path, "utf8")); + return typeof parsed === "object" && parsed !== null ? (parsed as Registry) : {}; + } catch { + return {}; // missing or corrupt: it is an index, so rebuild rather than fail + } +} + +export function saveRegistry(registry: Registry, env?: NodeJS.ProcessEnv): void { + const path = registryPath(env); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, `${JSON.stringify(registry, null, 2)}\n`); +} + +/** + * Session name for a root. + * + * Sanitised to something safe to type and to pass as a session name, and + * disambiguated with a short hash when another root already claims the name — + * two checkouts both called `api` must not fight over one session. + */ +export function sessionNameFor(root: string, registry: Registry = {}): string { + const cleaned = basename(resolve(root)).replace(/[^A-Za-z0-9_-]/g, "-").replace(/^-+|-+$/g, ""); + const base = cleaned.length > 0 ? cleaned : "root"; + const claimed = registry[base]; + if (claimed === undefined || resolve(claimed) === resolve(root)) return base; + return `${base}-${createHash("sha256").update(resolve(root)).digest("hex").slice(0, 6)}`; +} + +export interface ResolvedRoot { + root: string; + session: string; + /** Set when the CWD is itself a project, so `sl0ppi` can focus its tab. */ + project?: string; +} + +/** + * Work out which root the user means. + * + * --root DIR -> that, explicitly + * inside a project-> its parent is the root, and we remember which project + * inside a session-> the root the session is registered against + * otherwise -> the cwd is the root + */ +export function resolveRoot(options: { + rootFlag?: string; + cwd: string; + sessionEnv?: string | null; + registry?: Registry; +}): ResolvedRoot { + const registry = options.registry ?? {}; + const cwd = resolve(options.cwd); + + if (options.rootFlag) { + const root = resolve(options.rootFlag); + return { root, session: sessionNameFor(root, registry) }; + } + + if (isProjectDir(cwd)) { + const root = dirname(cwd); + return { root, session: sessionNameFor(root, registry), project: basename(cwd) }; + } + + if (options.sessionEnv && registry[options.sessionEnv]) { + const root = resolve(registry[options.sessionEnv] as string); + return { root, session: options.sessionEnv }; + } + + return { root: cwd, session: sessionNameFor(cwd, registry) }; +} + +/** Record that `session` belongs to `root`, so later runs can find it. */ +export function rememberRoot(session: string, root: string, env?: NodeJS.ProcessEnv): void { + const registry = loadRegistry(env); + const resolved = resolve(root); + if (registry[session] === resolved) return; + registry[session] = resolved; + saveRegistry(registry, env); +} + +export function rootExists(root: string): boolean { + return existsSync(root); +} |
