UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

250 lines • 11 kB
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. ========= // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // ========= Copyright 2026 @ Strukto.AI All Rights Reserved. ========= import { linkView } from "../../../../commands/builtin/utils/links.js"; import { dispatchStat, dotRefusal } from "../../../../commands/builtin/utils/paths.js"; import { fsStrerror, isEnoent } from "../../../../errors/fs.js"; import { IOResult } from "../../../../io/types.js"; import { encodeText } from "../../../../shell/bytes.js"; import { FileType, PathSpec } from "../../../../types.js"; import { CycleError, dottedSpelling, posixNormpath, resolvePath } from "../../../../utils/path.js"; import { classifyBarePath } from "../../../expand/classify/index.js"; import { Files } from "../../../files.js"; import { changeDir, homeDir, logicalCwd } from "../../../session/shell_dirs.js"; import { ExecutionNode } from "../../../types.js"; import { scopePath, toScope } from "../scope.js"; import { builtinUsage } from "../shared.js"; import { CD_OPTIONS } from "./constants.js"; import { joinRaw, resolveTarget, splitModeOptions, typedPath } from "./dirs.js"; function cdpathSearchable(target) { if (target.startsWith('/') || target.startsWith('./') || target.startsWith('../')) { return false; } return target !== '.' && target !== '..'; } // `cwd` is the directory a relative operand joins to: the logical cwd // under -L, the physical one under -P. Each candidate carries the spelling // whose `.` and `..` bash checks (`dottedSpelling`), taken against the // candidate's own base. function cdCandidates(raw, cdpathTarget, session, cwd) { const fallback = [ joinRaw(raw, cwd), false, dottedSpelling(raw, cwd), ]; const cdpath = session.env.CDPATH; if (!cdpath || !cdpathTarget || !cdpathSearchable(cdpathTarget)) { return [fallback]; } const out = []; for (const entry of cdpath.split(':')) { const base = entry ? resolvePath(entry, cwd) : cwd; out.push([joinRaw(cdpathTarget, base), entry !== '', dottedSpelling(cdpathTarget, base)]); } out.push(fallback); return out; } // Stat a name the way cd resolves one, through its links: the walk that // proves a name in front of `..` a directory has to see the links the operand // itself is resolved through. Mirrors Python's _linked_stat. function linkedStat(dispatch, follow) { const stat = dispatchStat(dispatch); return (path) => stat(follow !== null ? PathSpec.fromStrPath(resolveTarget(path.virtual, follow, true)) : path); } // The links cd resolves through, as the session sees them; null while the // namespace holds none. Mirrors Python's _session_follow. function sessionFollow(namespace, dispatch, session) { if (namespace.sessionLinks(session).symlinkTargets().size === 0) return null; const links = linkView(new Files(dispatch, namespace.readers(() => session.visibility), null, { clock: namespace.clock, })); return links === undefined ? null : (path) => links.resolve(path); } export async function handleCd(dispatch, isMountRoot, path, session, printPath = false, cdpathTarget = null, follow = null, physical = false) { const raw = scopePath(path); const named = typedPath(path); // -L joins a relative operand to the name the shell is *spelling*, -P // to the one it resolves to: from a logical /data/lk whose target is // /data/deep/real, bash sends `cd -L ..` to /data and `cd -P ..` to // /data/deep. const base = physical ? session.cwd : logicalCwd(session); const candidates = cdCandidates(typedPath(path), cdpathTarget, session, base); let error = null; for (const [candidate, announce, dotted] of candidates) { // bash simplifies `..` textually under -L, but only once each name in // front of one is proved a directory, the check its own // canonicalization makes; `cd nope/..` does not reach the cwd. if (dotted !== null) { const walk = PathSpec.fromStrPath(dotted, undefined, '/'); let refusal; try { refusal = await dotRefusal(linkedStat(dispatch, follow), walk); } catch (exc) { if (!(exc instanceof CycleError)) throw exc; error = `bash: cd: ${named}: Too many levels of symbolic links\n`; continue; } if (refusal !== null) { error = `bash: cd: ${named}: ${String(fsStrerror(refusal))}\n`; continue; } } // The logical name is the candidate with `..` simplified textually // and links left alone; the physical one follows them. -P collapses // the pair, which is why `cd -P .` re-spells the cwd. const spelled = posixNormpath(candidate); let logical = spelled; let resolved = logical; if (follow !== null) { try { resolved = resolveTarget(candidate, follow, physical); } catch (exc) { if (exc instanceof CycleError) { error = `bash: cd: ${named}: Too many levels of symbolic links\n`; continue; } throw exc; } } if (physical) logical = resolved; if (resolved === '/') { return cdSuccess(session, '/', logical, spelled, raw, printPath || announce); } const scope = toScope(resolved); let stat = null; let notFound = false; try { const [s] = await dispatch('stat', scope); stat = s; } catch (exc) { if (isEnoent(exc)) { notFound = true; } else { const msg = exc instanceof Error ? exc.message : String(exc); error = `bash: cd: ${named}: ${fsStrerror(exc) ?? msg}\n`; continue; } } if (stat === null || notFound) { if (isMountRoot(resolved)) { return cdSuccess(session, resolved, logical, spelled, raw, printPath || announce); } error = `bash: cd: ${named}: No such file or directory\n`; continue; } if (stat.type !== FileType.DIRECTORY) { error = `bash: cd: ${named}: Not a directory\n`; continue; } return cdSuccess(session, resolved, logical, spelled, raw, printPath || announce); } const err = encodeText(error ?? `bash: cd: ${named}: No such file or directory\n`); return [ null, new IOResult({ exitCode: 1, stderr: err }), new ExecutionNode({ command: `cd ${raw}`, exitCode: 1, stderr: err }), ]; } // Land the session on `resolved` and print what GNU prints. `logical` is // the name to remember as the cwd's spelling — `resolved` under -P, which // collapses the pair. `spelled` is the path as selected, `..` simplified // but links intact: what GNU announces, and NOT the same as `logical` // under -P, since `cd -P -` prints /tmp/lk and then lands on // /tmp/deep/real, and a -P $CDPATH hit prints /opt/c/lnk while landing on // /opt/c/t. function cdSuccess(session, resolved, logical, spelled, raw, printPath) { changeDir(session, resolved, logical); const out = printPath ? encodeText(`${spelled}\n`) : null; return [out, new IOResult(), new ExecutionNode({ command: `cd ${raw}`, exitCode: 0 })]; } /** * The `cd` arm: split -L/-P, pick the target, then move. `set -P` * (`set -o physical`) is the session-wide version of the per-command * flag, and GNU applies it to both `cd` and `pwd`. */ export async function cdBuiltin(call) { const { dispatch, registry, namespace } = call; const { session } = call.context; const shellPhysical = session.shellOptions.physical === true; const { operands: cdOperands, bad, physical, } = splitModeOptions([...call.argv.operands], CD_OPTIONS, shellPhysical); const follow = sessionFollow(namespace, dispatch, session); // A mount root whose stat answers ENOENT is still a place to land, but // only one the session sees: a hidden root answers ENOENT too. const reader = namespace.sessionLinks(session); const isMount = (p) => reader.isMount(p); if (bad !== null) { const err = encodeText(`bash: cd: -${bad}: invalid option\n${builtinUsage('cd')}`); return [ null, new IOResult({ exitCode: 2, stderr: err }), new ExecutionNode({ command: 'cd', exitCode: 2, stderr: err }), ]; } if (cdOperands.length > 1) { const err = encodeText('bash: cd: too many arguments\n'); return [ null, new IOResult({ exitCode: 1, stderr: err }), new ExecutionNode({ command: 'cd', exitCode: 1, stderr: err }), ]; } if (cdOperands.length === 0) { const home = homeDir(session); if (home === null) { const err = encodeText('bash: cd: HOME not set\n'); return [ null, new IOResult({ exitCode: 1, stderr: err }), new ExecutionNode({ command: 'cd', exitCode: 1, stderr: err }), ]; } return handleCd(dispatch, isMount, home, session, false, null, follow, physical); } const raw = cdOperands[0]; const rawStr = raw instanceof PathSpec ? raw.virtual : String(raw); if (rawStr === '-') { const old = session.env.OLDPWD; if (!old) { const err = encodeText('bash: cd: OLDPWD not set\n'); return [ null, new IOResult({ exitCode: 1, stderr: err }), new ExecutionNode({ command: 'cd -', exitCode: 1, stderr: err }), ]; } return handleCd(dispatch, isMount, old, session, true, null, follow, physical); } let path; let cdpathTarget; if (raw instanceof PathSpec) { path = raw; cdpathTarget = raw.rawPath; } else if (rawStr.startsWith('/')) { path = rawStr; cdpathTarget = rawStr; } else { path = classifyBarePath(rawStr, registry, session.cwd); cdpathTarget = rawStr; } return handleCd(dispatch, isMount, path, session, false, cdpathTarget, follow, physical); } //# sourceMappingURL=cd.js.map