@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
250 lines • 11 kB
JavaScript
// ========= 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