diff --git a/experiments/cd-edge.mjs b/experiments/cd-edge.mjs
new file mode 100644
index 0000000..51fa7cb
--- /dev/null
+++ b/experiments/cd-edge.mjs
@@ -0,0 +1,8 @@
+import { $ } from '../js/src/$.mjs';
+process.chdir('/tmp');
+async function t(cmd){ try{const r=await $`${{raw:cmd}}`; return `code=${r.code} out=${JSON.stringify((r.stdout||'').toString().trim())} err=${JSON.stringify((r.stderr||'').toString().trim())}`;}catch(e){return 'THROW '+e.message;}}
+console.log('cd ~ ->', await t('cd ~'), 'cwd:', process.cwd());
+process.chdir('/tmp');
+console.log('cd ->', await t('cd'), 'cwd:', process.cwd());
+process.chdir('/tmp');
+console.log('cd ~/ ->', await t('cd ~/'), 'cwd:', process.cwd());
diff --git a/experiments/cd-sh-comparison.mjs b/experiments/cd-sh-comparison.mjs
new file mode 100644
index 0000000..64f3a30
--- /dev/null
+++ b/experiments/cd-sh-comparison.mjs
@@ -0,0 +1,31 @@
+import { $ } from '../js/src/$.mjs';
+
+const tmp = '/tmp/cs-cd-test';
+await $`rm -rf ${tmp}`;
+await $`mkdir -p ${tmp}/sub`;
+
+console.log('=== Test 1: cd - (previous dir) ===');
+try {
+ process.chdir(tmp);
+ await $`cd sub`;
+ console.log('after cd sub, cwd=', process.cwd());
+ const r = await $`cd -`;
+ console.log('cd - stdout:', JSON.stringify(r.stdout), 'code:', r.code, 'cwd now:', process.cwd());
+} catch(e) { console.log('cd - error:', e.message); }
+
+console.log('=== Test 2: $PWD env var ===');
+process.chdir(tmp);
+const pwdEnv = await $`echo $PWD`;
+console.log('echo $PWD ->', JSON.stringify(pwdEnv.stdout), ' actual process.cwd:', process.cwd());
+
+console.log('=== Test 3: subshell isolation (cd x); pwd ===');
+process.chdir(tmp);
+const sub = await $`(cd sub && pwd) ; pwd`;
+console.log('subshell stdout:', JSON.stringify(sub.stdout));
+console.log('process.cwd after subshell:', process.cwd());
+
+console.log('=== Test 4: cwd option with cd ===');
+const r4 = await $({cwd: tmp})`cd sub && pwd`;
+console.log('cwd-option cd sub && pwd ->', JSON.stringify(r4.stdout), 'code', r4.code);
+
+process.chdir('/tmp');
diff --git a/experiments/env-expand.mjs b/experiments/env-expand.mjs
new file mode 100644
index 0000000..acc3cf7
--- /dev/null
+++ b/experiments/env-expand.mjs
@@ -0,0 +1,6 @@
+import { $ } from '../js/src/$.mjs';
+process.chdir('/tmp');
+for (const cmd of ['echo $HOME', 'echo $PWD', 'echo $OLDPWD', 'echo ~']) {
+ const r = await $`${{raw: cmd}}`.catch(e=>({stdout:'ERR '+e.message}));
+ console.log(cmd, '->', JSON.stringify((r.stdout||'').toString().trim()));
+}
diff --git a/experiments/env2.mjs b/experiments/env2.mjs
new file mode 100644
index 0000000..ce4ba0c
--- /dev/null
+++ b/experiments/env2.mjs
@@ -0,0 +1,8 @@
+import { $ } from '../js/src/$.mjs';
+process.chdir('/tmp');
+let r = await $`echo $HOME`;
+console.log('virtual echo $HOME ->', JSON.stringify(r.stdout.toString().trim()));
+r = await $`/bin/echo $HOME`;
+console.log('/bin/echo $HOME ->', JSON.stringify(r.stdout.toString().trim()));
+r = await $`/bin/echo hi && /bin/echo $HOME`;
+console.log('chained /bin/echo $HOME ->', JSON.stringify(r.stdout.toString().trim()));
diff --git a/js/.changeset/cd-sh-compatibility.md b/js/.changeset/cd-sh-compatibility.md
new file mode 100644
index 0000000..2c73b9d
--- /dev/null
+++ b/js/.changeset/cd-sh-compatibility.md
@@ -0,0 +1,12 @@
+---
+'command-stream': minor
+---
+
+Make the built-in `cd` command fully `sh`/bash compatible so shell scripts translate directly to `.mjs` (issue #50):
+
+- `cd -` switches to the previous directory and prints it, like `sh`
+- `~` and `~/path` tilde expansion
+- successful `cd` updates the `PWD` and `OLDPWD` environment variables
+- relative targets resolve against the `cwd` option for consistency
+
+Also documents the working-directory behavior (persistence across commands, subshell isolation, and `cd` vs. the `cwd` option) in the README.
diff --git a/js/README.md b/js/README.md
index b2705b0..a519fa3 100644
--- a/js/README.md
+++ b/js/README.md
@@ -627,6 +627,81 @@ const numbers = await $`cat numbers.txt`;
await $`rm -r project numbers.txt`;
```
+### Working Directory (`cd` and the `cwd` option)
+
+The built-in `cd` command behaves like `cd` in a POSIX `sh`/bash script, so shell
+scripts translate directly. Crucially, **a directory change persists across
+subsequent commands** — just like running successive lines in a shell script:
+
+```javascript
+import { $ } from 'command-stream';
+
+// In sh: // In command-stream (.mjs):
+// cd /some/directory await $`cd /some/directory`;
+// pwd # -> /some/directory await $`pwd`; // -> /some/directory
+```
+
+This is the behavior described in [issue #50](https://github.com/link-foundation/command-stream/issues/50):
+each `cd` updates the process working directory, so the next command starts from
+the new location. All of the following sh idioms work identically:
+
+```javascript
+await $`cd /tmp && pwd`; // chain with && -> /tmp
+await $`cd /tmp`;
+await $`pwd`; // separate commands -> /tmp (change persists)
+await $`cd`; // no argument -> $HOME
+await $`cd ~`; // ~ expands to $HOME
+await $`cd ~/projects`; // ~/ prefix expands to $HOME/projects
+await $`cd ..`; // parent directory
+await $`cd -`; // previous directory (prints it, like sh)
+await $`cd /tmp && mkdir t && cd t && pwd`; // -> /tmp/t
+```
+
+A successful `cd` updates the `PWD` and `OLDPWD` environment variables (used by
+`cd -`), exactly like a real shell. A failed `cd` prints a `sh`-style error to
+stderr, returns a non-zero exit code, and leaves the working directory unchanged:
+
+```javascript
+const r = await $`cd /does/not/exist`;
+console.log(r.code); // 1
+console.log(r.stderr); // cd: ENOENT: no such file or directory, ...
+```
+
+#### Subshell isolation with `( … )`
+
+As in sh, a `cd` inside a subshell `( … )` does **not** leak to the parent:
+
+```javascript
+process.chdir('/tmp');
+await $`(cd /usr && pwd) ; pwd`;
+// -> /usr (inside the subshell)
+// -> /tmp (parent directory is unchanged)
+```
+
+#### `cd` vs. the `cwd` option
+
+There are two ways to control the working directory; pick whichever maps best to
+the script you are translating:
+
+```javascript
+// 1) cd command — mutates the process working directory and persists,
+// just like a line in a shell script.
+await $`cd /tmp`;
+await $`pwd`; // -> /tmp
+
+// 2) cwd option — sets a fixed working directory for a single invocation
+// (or a reusable $({ cwd }) binding) without changing process.cwd().
+await $({ cwd: '/tmp' })`pwd`; // -> /tmp, process.cwd() is untouched
+```
+
+Relative `cd` targets are resolved against the `cwd` option when one is provided,
+so `` $({ cwd: '/tmp' })`cd sub` `` changes into `/tmp/sub`.
+
+> **Note:** Virtual commands such as `echo` do not perform shell variable
+> expansion, so `echo $PWD` prints the literal string `$PWD`. Use `process.cwd()`
+> in JavaScript, the `pwd` command, or a real binary (e.g. `/bin/echo $PWD`) when
+> you need the expanded value.
+
### Virtual Commands (Extensible Shell)
Create custom commands that work seamlessly alongside built-ins:
diff --git a/js/examples/cd-cwd-sh-translation.mjs b/js/examples/cd-cwd-sh-translation.mjs
new file mode 100644
index 0000000..d11cbef
--- /dev/null
+++ b/js/examples/cd-cwd-sh-translation.mjs
@@ -0,0 +1,50 @@
+#!/usr/bin/env node
+// Example for issue #50: translating `cd` patterns from sh to .mjs.
+//
+// The built-in `cd` command behaves like `cd` in a POSIX sh/bash script, so a
+// shell script translates almost line-for-line. Run with:
+// node examples/cd-cwd-sh-translation.mjs
+import { $ } from '../src/$.mjs';
+import { mkdtempSync, rmSync } from 'fs';
+import { tmpdir } from 'os';
+import { join } from 'path';
+
+const root = mkdtempSync(join(tmpdir(), 'cd-demo-'));
+
+try {
+ // ---- sh: cd /dir && pwd ----------------------------------------------------
+ // cd /dir
+ // pwd # -> /dir
+ let r = await $`cd ${root} && pwd`;
+ console.log('cd && pwd ->', r.stdout.trim());
+
+ // ---- sh: the change persists across separate commands ----------------------
+ // cd /dir
+ // pwd # still /dir on the next line
+ await $`cd ${root}`;
+ r = await $`pwd`;
+ console.log('cd; then pwd ->', r.stdout.trim());
+
+ // ---- sh: nested cd within a chain ------------------------------------------
+ await $`mkdir -p ${join(root, 'build')}`;
+ r = await $`cd ${root} && cd build && pwd`;
+ console.log('cd a && cd b ->', r.stdout.trim());
+
+ // ---- sh: cd - returns to the previous directory and prints it --------------
+ await $`cd ${root}`;
+ await $`cd ${join(root, 'build')}`;
+ r = await $`cd -`;
+ console.log('cd - ->', r.stdout.trim(), '(printed, like sh)');
+
+ // ---- sh: subshell isolation — (cd x) does not affect the parent ------------
+ await $`cd ${root}`;
+ r = await $`(cd build && pwd) ; pwd`;
+ console.log('(cd b); pwd ->', JSON.stringify(r.stdout.trim()));
+
+ // ---- the cwd option: a fixed directory without changing process.cwd() ------
+ r = await $({ cwd: root })`pwd`;
+ console.log('cwd option ->', r.stdout.trim());
+} finally {
+ process.chdir(tmpdir());
+ rmSync(root, { recursive: true, force: true });
+}
diff --git a/js/src/commands/$.cd.mjs b/js/src/commands/$.cd.mjs
index eaa6520..a568c6a 100644
--- a/js/src/commands/$.cd.mjs
+++ b/js/src/commands/$.cd.mjs
@@ -1,21 +1,71 @@
+import path from 'path';
import { trace, VirtualUtils } from '../$.utils.mjs';
-export default async function cd({ args }) {
- const target = args[0] || process.env.HOME || process.env.USERPROFILE || '/';
+/**
+ * Virtual `cd` command.
+ *
+ * Mirrors POSIX `sh`/bash semantics so that shell scripts translate directly:
+ * - `cd` -> change to $HOME (or $USERPROFILE on Windows)
+ * - `cd ~`/`cd ~/x` -> tilde expands to $HOME
+ * - `cd -` -> change to $OLDPWD and print the new directory (like sh)
+ * - `cd
` -> change to (relative paths resolve against the
+ * current working directory, or the `cwd` option)
+ *
+ * Like a real shell, a successful `cd` updates the `PWD` and `OLDPWD`
+ * environment variables and changes the Node.js process directory so that
+ * subsequent commands (virtual or real) observe the new location.
+ */
+export default async function cd({ args, cwd }) {
+ const home = process.env.HOME || process.env.USERPROFILE || '/';
+ const base = cwd || process.cwd();
+ const previousDir = process.cwd();
+
+ let target = args[0];
+ let printDir = false;
+
+ if (target === undefined || target === '') {
+ // `cd` with no argument goes to $HOME, just like sh.
+ target = home;
+ } else if (target === '-') {
+ // `cd -` switches to the previous directory and prints it (sh behavior).
+ const oldpwd = process.env.OLDPWD;
+ if (!oldpwd) {
+ trace('VirtualCommand', () => 'cd: OLDPWD not set');
+ return { stdout: '', stderr: 'cd: OLDPWD not set\n', code: 1 };
+ }
+ target = oldpwd;
+ printDir = true;
+ } else if (target === '~') {
+ target = home;
+ } else if (target.startsWith('~/')) {
+ target = path.join(home, target.slice(2));
+ }
+
+ // Resolve relative targets against the effective base directory so that the
+ // `cwd` option and chained `cd` commands behave consistently.
+ const resolved = path.isAbsolute(target)
+ ? target
+ : path.resolve(base, target);
+
trace(
'VirtualCommand',
- () => `cd: changing directory | ${JSON.stringify({ target }, null, 2)}`
+ () =>
+ `cd: changing directory | ${JSON.stringify({ target, resolved }, null, 2)}`
);
try {
- process.chdir(target);
+ process.chdir(resolved);
const newDir = process.cwd();
+ // Keep PWD/OLDPWD in sync with the real shell so `$PWD`-style lookups and
+ // child processes observe the change.
+ process.env.OLDPWD = previousDir;
+ process.env.PWD = newDir;
trace(
'VirtualCommand',
() => `cd: success | ${JSON.stringify({ newDir }, null, 2)}`
);
- // cd command should not output anything on success, just like real cd
- return VirtualUtils.success('');
+ // A successful `cd` is silent, except for `cd -` which echoes the new dir.
+ return VirtualUtils.success(printDir ? newDir + '\n' : '');
} catch (error) {
trace(
'VirtualCommand',
diff --git a/js/tests/cd-virtual-command.test.mjs b/js/tests/cd-virtual-command.test.mjs
index 18d3038..4bb0583 100644
--- a/js/tests/cd-virtual-command.test.mjs
+++ b/js/tests/cd-virtual-command.test.mjs
@@ -145,9 +145,15 @@ describe.skipIf(isWindows)('cd Virtual Command - Core Behavior', () => {
const pwd2 = await $`pwd`;
expect(normalizePath(pwd2.stdout.trim())).toBe(normalizePath(dir2));
- // Note: cd - might not be implemented in virtual command yet
- // This test documents expected behavior
- const result = await $`cd - 2>&1 || echo "not implemented"`;
+ // `cd -` switches back to the previous directory and prints it,
+ // exactly like POSIX sh/bash.
+ const result = await $`cd -`;
+ expect(result.code).toBe(0);
+ expect(normalizePath(result.stdout.trim())).toBe(normalizePath(dir1));
+ expect(normalizePath(process.cwd())).toBe(normalizePath(dir1));
+
+ const pwd3 = await $`pwd`;
+ expect(normalizePath(pwd3.stdout.trim())).toBe(normalizePath(dir1));
await $`cd ${originalCwd}`;
} finally {
diff --git a/js/tests/cwd-cd-pattern-issue.test.mjs b/js/tests/cwd-cd-pattern-issue.test.mjs
new file mode 100644
index 0000000..4bad3fa
--- /dev/null
+++ b/js/tests/cwd-cd-pattern-issue.test.mjs
@@ -0,0 +1,277 @@
+import { test, expect, describe, beforeEach, afterEach } from 'bun:test';
+import {
+ beforeTestCleanup,
+ afterTestCleanup,
+ originalCwd,
+} from './test-cleanup.mjs';
+import { $ } from '../src/$.mjs';
+import { isWindows } from './test-helper.mjs';
+import { mkdtempSync, rmSync, writeFileSync, realpathSync } from 'fs';
+import { tmpdir } from 'os';
+import { join } from 'path';
+
+// Normalize paths to handle macOS /var -> /private/var symlink resolution so
+// comparisons against process.cwd() are stable across platforms.
+const normalizePath = (p) => {
+ try {
+ return realpathSync(p);
+ } catch {
+ return p;
+ }
+};
+
+// These scenarios rely on POSIX shell semantics (pwd, ls, cat, mkdir -p,
+// `&&` chaining, git status format), which the cmd.exe-backed Windows runner
+// does not provide. Skip on Windows like the repo's other Unix-shell suites.
+describe.skipIf(isWindows)('Issue #50: CWD with CD pattern failure', () => {
+ let testDir;
+
+ beforeEach(async () => {
+ await beforeTestCleanup();
+ // Normalize so comparisons against process.cwd()/pwd are stable on macOS,
+ // where the temp dir lives under /var but resolves to /private/var.
+ testDir = normalizePath(mkdtempSync(join(tmpdir(), 'issue-50-')));
+ });
+
+ afterEach(async () => {
+ // Ensure we're back in original directory
+ process.chdir(originalCwd);
+ if (testDir) {
+ rmSync(testDir, { recursive: true, force: true });
+ }
+ await afterTestCleanup();
+ });
+
+ test('should handle separate cd and pwd commands correctly', async () => {
+ // Start from original directory
+ process.chdir(originalCwd);
+
+ // Run cd command
+ const cdResult = await $`cd ${testDir}`;
+ expect(cdResult.code).toBe(0);
+
+ // Check that Node.js CWD actually changed
+ expect(process.cwd()).toBe(testDir);
+
+ // Run pwd command
+ const pwdResult = await $`pwd`;
+ expect(pwdResult.code).toBe(0);
+ expect(pwdResult.stdout.trim()).toBe(testDir);
+ });
+
+ test('should handle cd && pwd pattern correctly', async () => {
+ process.chdir(originalCwd);
+
+ const result = await $`cd ${testDir} && pwd`;
+ expect(result.code).toBe(0);
+ expect(result.stdout.trim()).toBe(testDir);
+
+ // Node.js CWD should also be changed by the virtual cd
+ expect(process.cwd()).toBe(testDir);
+ });
+
+ test('should handle git scenario from issue description', async () => {
+ const testFile = join(testDir, 'test.txt');
+ writeFileSync(testFile, 'Test content');
+
+ // Initialize git repo
+ await $({ cwd: testDir })`git init`;
+
+ // Start from original directory
+ process.chdir(originalCwd);
+
+ // This pattern was failing according to the issue
+ const addResult = await $`cd ${testDir} && git add test.txt`;
+ expect(addResult.code).toBe(0);
+
+ // Check git status to verify file was actually added
+ const statusResult = await $({ cwd: testDir })`git status --short`;
+ const status = statusResult.stdout.toString().trim();
+
+ // File should be staged (A test.txt) not untracked (?? test.txt)
+ expect(status).toContain('A test.txt');
+ expect(status).not.toContain('??');
+ });
+
+ test('should maintain directory changes across multiple shell operations', async () => {
+ const subDir = join(testDir, 'subdir');
+ const subSubDir = join(subDir, 'subsubdir');
+
+ // Create nested directories
+ await $({ cwd: testDir })`mkdir -p subdir/subsubdir`;
+
+ process.chdir(originalCwd);
+
+ // Chain multiple cd operations
+ const result = await $`cd ${testDir} && cd subdir && cd subsubdir && pwd`;
+ expect(result.code).toBe(0);
+ expect(result.stdout.trim()).toBe(subSubDir);
+
+ // Check that Node.js CWD reflects the final directory
+ expect(process.cwd()).toBe(subSubDir);
+ });
+
+ test('should handle complex build scenario with cd pattern', async () => {
+ const srcDir = join(testDir, 'src');
+ const buildFile = join(srcDir, 'index.js');
+
+ // Create source directory and file
+ await $({ cwd: testDir })`mkdir src`;
+ writeFileSync(buildFile, 'console.log("Hello from build");');
+
+ process.chdir(originalCwd);
+
+ // Simulate build process that depends on being in correct directory
+ const buildResult = await $`cd ${testDir} && ls src && cat src/index.js`;
+ expect(buildResult.code).toBe(0);
+ expect(buildResult.stdout).toContain('index.js');
+ expect(buildResult.stdout).toContain('Hello from build');
+ });
+
+ test('should work with relative paths after cd', async () => {
+ const subDir = join(testDir, 'relative-test');
+ await $({ cwd: testDir })`mkdir relative-test`;
+
+ process.chdir(originalCwd);
+
+ // Use cd then relative paths
+ const result = await $`cd ${testDir} && cd relative-test && pwd`;
+ expect(result.code).toBe(0);
+ expect(result.stdout.trim()).toBe(subDir);
+ });
+
+ test('should handle cwd option vs cd command consistency', async () => {
+ const testFile = join(testDir, 'consistency.txt');
+ writeFileSync(testFile, 'test content');
+
+ process.chdir(originalCwd);
+
+ // Method 1: Using cd command
+ const cdMethod = await $`cd ${testDir} && cat consistency.txt`;
+
+ // Reset directory
+ process.chdir(originalCwd);
+
+ // Method 2: Using cwd option
+ const cwdMethod = await $({ cwd: testDir })`cat consistency.txt`;
+
+ // Both methods should produce the same result
+ expect(cdMethod.code).toBe(0);
+ expect(cwdMethod.code).toBe(0);
+ expect(cdMethod.stdout).toBe(cwdMethod.stdout);
+ expect(cdMethod.stdout.trim()).toBe('test content');
+ });
+
+ test('should handle error cases correctly', async () => {
+ const nonExistentDir = join(testDir, 'does-not-exist');
+
+ process.chdir(originalCwd);
+
+ // cd to non-existent directory should fail
+ const result = await $`cd ${nonExistentDir} && pwd`;
+ expect(result.code).not.toBe(0);
+
+ // Should remain in original directory
+ expect(process.cwd()).toBe(originalCwd);
+ });
+});
+
+// sh-translation semantics ($HOME, ~ expansion, cd -, $PWD/$OLDPWD) are POSIX
+// shell concepts; skip on the cmd.exe-backed Windows runner.
+describe.skipIf(isWindows)(
+ 'Issue #50: cd sh-compatibility (drop-in translation from sh)',
+ () => {
+ let testDir;
+
+ beforeEach(async () => {
+ await beforeTestCleanup();
+ testDir = normalizePath(
+ mkdtempSync(join(tmpdir(), 'issue-50-shcompat-'))
+ );
+ });
+
+ afterEach(async () => {
+ process.chdir(originalCwd);
+ if (testDir) {
+ rmSync(testDir, { recursive: true, force: true });
+ }
+ await afterTestCleanup();
+ });
+
+ test('cd with no argument goes to $HOME (like sh)', async () => {
+ process.chdir(testDir);
+ const result = await $`cd`;
+ expect(result.code).toBe(0);
+ expect(normalizePath(process.cwd())).toBe(
+ normalizePath(process.env.HOME)
+ );
+ });
+
+ test('cd ~ expands tilde to $HOME (like sh)', async () => {
+ process.chdir(testDir);
+ const result = await $`cd ~`;
+ expect(result.code).toBe(0);
+ expect(normalizePath(process.cwd())).toBe(
+ normalizePath(process.env.HOME)
+ );
+ });
+
+ test('cd ~/subpath expands tilde prefix (like sh)', async () => {
+ process.chdir(testDir);
+ const result = await $`cd ~/`;
+ expect(result.code).toBe(0);
+ expect(normalizePath(process.cwd())).toBe(
+ normalizePath(process.env.HOME)
+ );
+ });
+
+ test('cd - switches to previous directory and prints it (like sh)', async () => {
+ const dirA = normalizePath(mkdtempSync(join(tmpdir(), 'issue-50-a-')));
+ const dirB = normalizePath(mkdtempSync(join(tmpdir(), 'issue-50-b-')));
+ try {
+ await $`cd ${dirA}`;
+ await $`cd ${dirB}`;
+ const result = await $`cd -`;
+ expect(result.code).toBe(0);
+ // sh prints the new (previous) directory on `cd -`
+ expect(normalizePath(result.stdout.trim())).toBe(dirA);
+ expect(normalizePath(process.cwd())).toBe(dirA);
+ } finally {
+ process.chdir(originalCwd);
+ rmSync(dirA, { recursive: true, force: true });
+ rmSync(dirB, { recursive: true, force: true });
+ }
+ });
+
+ test('cd updates PWD and OLDPWD environment variables (like sh)', async () => {
+ const start = normalizePath(mkdtempSync(join(tmpdir(), 'issue-50-pwd-')));
+ try {
+ await $`cd ${start}`;
+ expect(normalizePath(process.env.PWD)).toBe(start);
+ await $`cd ${testDir}`;
+ expect(normalizePath(process.env.PWD)).toBe(testDir);
+ expect(normalizePath(process.env.OLDPWD)).toBe(start);
+ } finally {
+ process.chdir(originalCwd);
+ rmSync(start, { recursive: true, force: true });
+ }
+ });
+
+ test('cd to a non-existent directory reports a sh-style error and keeps cwd', async () => {
+ process.chdir(testDir);
+ const result = await $`cd ${join(testDir, 'nope')}`;
+ expect(result.code).toBe(1);
+ expect(result.stderr).toContain('cd:');
+ expect(normalizePath(process.cwd())).toBe(testDir);
+ });
+
+ test('relative cd resolves against the cwd option', async () => {
+ const subDir = join(testDir, 'sub');
+ await $({ cwd: testDir })`mkdir sub`;
+ process.chdir(originalCwd);
+ const result = await $({ cwd: testDir })`cd sub`;
+ expect(result.code).toBe(0);
+ expect(normalizePath(process.cwd())).toBe(normalizePath(subDir));
+ });
+ }
+);
diff --git a/rust/changelog.d/20260610_101606_cd-sh-compatibility.md b/rust/changelog.d/20260610_101606_cd-sh-compatibility.md
new file mode 100644
index 0000000..9bbf28b
--- /dev/null
+++ b/rust/changelog.d/20260610_101606_cd-sh-compatibility.md
@@ -0,0 +1,10 @@
+---
+bump: minor
+---
+
+### Changed
+- Make the built-in `cd` command fully `sh`/bash compatible so shell scripts translate directly to Rust (issue #50):
+ - `cd -` switches to the previous directory and prints it, like `sh`
+ - `~` and `~/path` tilde expansion
+ - a successful `cd` updates the `PWD` and `OLDPWD` environment variables
+ - relative targets resolve against the `cwd` option for consistency
diff --git a/rust/src/commands/cd.rs b/rust/src/commands/cd.rs
index 1c84b48..37dd23b 100644
--- a/rust/src/commands/cd.rs
+++ b/rust/src/commands/cd.rs
@@ -7,35 +7,79 @@ use std::path::PathBuf;
/// Execute the cd command
///
-/// Changes the current working directory.
+/// Mirrors POSIX `sh`/bash semantics so that shell scripts translate directly:
+/// - `cd` -> change to $HOME (or $USERPROFILE on Windows)
+/// - `cd ~`/`cd ~/x` -> tilde expands to $HOME
+/// - `cd -` -> change to $OLDPWD and print the new directory (like sh)
+/// - `cd ` -> change to (relative paths resolve against the
+/// current working directory, or the `cwd` option)
+///
+/// Like a real shell, a successful `cd` updates the `PWD` and `OLDPWD`
+/// environment variables and changes the process directory so that subsequent
+/// commands (virtual or real) observe the new location.
pub async fn cd(ctx: CommandContext) -> CommandResult {
- let target = if ctx.args.is_empty() {
- // No argument - go to home directory
- env::var("HOME")
- .or_else(|_| env::var("USERPROFILE"))
- .unwrap_or_else(|_| "/".to_string())
+ let home = env::var("HOME")
+ .or_else(|_| env::var("USERPROFILE"))
+ .unwrap_or_else(|_| "/".to_string());
+
+ let previous_dir = env::current_dir().ok();
+ let base = ctx.get_cwd();
+
+ let mut print_dir = false;
+ let target: String = match ctx.args.first().map(|s| s.as_str()) {
+ // `cd` with no argument goes to $HOME, just like sh.
+ None | Some("") => home.clone(),
+ // `cd -` switches to the previous directory and prints it (sh behavior).
+ Some("-") => match env::var("OLDPWD") {
+ Ok(oldpwd) if !oldpwd.is_empty() => {
+ print_dir = true;
+ oldpwd
+ }
+ _ => {
+ trace("VirtualCommand", "cd: OLDPWD not set");
+ return CommandResult::error("cd: OLDPWD not set\n");
+ }
+ },
+ Some("~") => home.clone(),
+ Some(t) if t.starts_with("~/") => PathBuf::from(&home).join(&t[2..]).display().to_string(),
+ Some(t) => t.to_string(),
+ };
+
+ // Resolve relative targets against the effective base directory so that the
+ // `cwd` option and chained `cd` commands behave consistently.
+ let target_path = PathBuf::from(&target);
+ let resolved = if target_path.is_absolute() {
+ target_path
} else {
- ctx.args[0].clone()
+ base.join(&target_path)
};
trace(
"VirtualCommand",
- &format!("cd: changing directory to {:?}", target),
+ &format!("cd: changing directory to {:?}", resolved),
);
- let path = PathBuf::from(&target);
-
- match env::set_current_dir(&path) {
+ match env::set_current_dir(&resolved) {
Ok(()) => {
let new_dir = env::current_dir()
.map(|p| p.display().to_string())
.unwrap_or_default();
+ // Keep PWD/OLDPWD in sync with the real shell so `$PWD`-style lookups
+ // and child processes observe the change.
+ if let Some(prev) = previous_dir {
+ env::set_var("OLDPWD", prev);
+ }
+ env::set_var("PWD", &new_dir);
trace(
"VirtualCommand",
&format!("cd: success, new dir: {}", new_dir),
);
- // cd command should not output anything on success
- CommandResult::success_empty()
+ // A successful `cd` is silent, except for `cd -` which echoes the dir.
+ if print_dir {
+ CommandResult::success(format!("{}\n", new_dir))
+ } else {
+ CommandResult::success_empty()
+ }
}
Err(e) => {
trace("VirtualCommand", &format!("cd: failed: {}", e));
@@ -47,10 +91,26 @@ pub async fn cd(ctx: CommandContext) -> CommandResult {
#[cfg(test)]
mod tests {
use super::*;
+ use std::path::Path;
use tempfile::tempdir;
+ use tokio::sync::Mutex;
+
+ // `cd` mutates process-global state (current dir + PWD/OLDPWD env vars).
+ // Rust runs tests in parallel by default, so serialize the cd tests against
+ // each other to avoid races on that shared state. An async-aware mutex lets
+ // the guard be held across the `cd(...).await` calls without tripping
+ // clippy's `await_holding_lock` lint.
+ static CD_TEST_LOCK: Mutex<()> = Mutex::const_new(());
+
+ // Normalize paths so comparisons survive symlinked temp dirs
+ // (e.g. macOS `/var` -> `/private/var`).
+ fn normalize(p: &Path) -> PathBuf {
+ std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf())
+ }
#[tokio::test]
async fn test_cd_to_temp() {
+ let _guard = CD_TEST_LOCK.lock().await;
let temp = tempdir().unwrap();
let temp_path = temp.path().to_string_lossy().to_string();
let original_dir = env::current_dir().unwrap();
@@ -59,6 +119,10 @@ mod tests {
let result = cd(ctx).await;
assert!(result.is_success());
assert_eq!(result.stdout, "");
+ assert_eq!(
+ normalize(&env::current_dir().unwrap()),
+ normalize(temp.path())
+ );
// Restore original directory
env::set_current_dir(original_dir).unwrap();
@@ -66,8 +130,155 @@ mod tests {
#[tokio::test]
async fn test_cd_to_nonexistent() {
+ let _guard = CD_TEST_LOCK.lock().await;
+ let original_dir = env::current_dir().unwrap();
let ctx = CommandContext::new(vec!["/nonexistent/path/12345".to_string()]);
let result = cd(ctx).await;
assert!(!result.is_success());
+ assert_eq!(result.code, 1);
+ assert!(result.stderr.contains("cd:"));
+ // A failed cd must not move the process out of its directory.
+ assert_eq!(env::current_dir().unwrap(), original_dir);
+ }
+
+ #[tokio::test]
+ async fn test_cd_no_arg_goes_home() {
+ let _guard = CD_TEST_LOCK.lock().await;
+ let temp = tempdir().unwrap();
+ let original_dir = env::current_dir().unwrap();
+ env::set_var("HOME", temp.path());
+
+ let ctx = CommandContext::new(vec![]);
+ let result = cd(ctx).await;
+ assert!(result.is_success());
+ assert_eq!(
+ normalize(&env::current_dir().unwrap()),
+ normalize(temp.path())
+ );
+
+ env::set_current_dir(original_dir).unwrap();
+ }
+
+ #[tokio::test]
+ async fn test_cd_tilde_expands_home() {
+ let _guard = CD_TEST_LOCK.lock().await;
+ let temp = tempdir().unwrap();
+ let original_dir = env::current_dir().unwrap();
+ env::set_var("HOME", temp.path());
+
+ let ctx = CommandContext::new(vec!["~".to_string()]);
+ let result = cd(ctx).await;
+ assert!(result.is_success());
+ assert_eq!(
+ normalize(&env::current_dir().unwrap()),
+ normalize(temp.path())
+ );
+
+ env::set_current_dir(original_dir).unwrap();
+ }
+
+ #[tokio::test]
+ async fn test_cd_tilde_subpath_expands() {
+ let _guard = CD_TEST_LOCK.lock().await;
+ let temp = tempdir().unwrap();
+ std::fs::create_dir(temp.path().join("sub")).unwrap();
+ let original_dir = env::current_dir().unwrap();
+ env::set_var("HOME", temp.path());
+
+ let ctx = CommandContext::new(vec!["~/sub".to_string()]);
+ let result = cd(ctx).await;
+ assert!(result.is_success());
+ assert_eq!(
+ normalize(&env::current_dir().unwrap()),
+ normalize(&temp.path().join("sub"))
+ );
+
+ env::set_current_dir(original_dir).unwrap();
+ }
+
+ #[tokio::test]
+ async fn test_cd_dash_switches_and_prints() {
+ let _guard = CD_TEST_LOCK.lock().await;
+ let dir_a = tempdir().unwrap();
+ let dir_b = tempdir().unwrap();
+ let original_dir = env::current_dir().unwrap();
+
+ let _ = cd(CommandContext::new(vec![dir_a
+ .path()
+ .to_string_lossy()
+ .to_string()]))
+ .await;
+ let _ = cd(CommandContext::new(vec![dir_b
+ .path()
+ .to_string_lossy()
+ .to_string()]))
+ .await;
+
+ let result = cd(CommandContext::new(vec!["-".to_string()])).await;
+ assert!(result.is_success());
+ // sh prints the previous directory on `cd -`.
+ assert_eq!(
+ normalize(Path::new(result.stdout.trim())),
+ normalize(dir_a.path())
+ );
+ assert_eq!(
+ normalize(&env::current_dir().unwrap()),
+ normalize(dir_a.path())
+ );
+
+ env::set_current_dir(original_dir).unwrap();
+ }
+
+ #[tokio::test]
+ async fn test_cd_updates_pwd_and_oldpwd() {
+ let _guard = CD_TEST_LOCK.lock().await;
+ let dir_a = tempdir().unwrap();
+ let dir_b = tempdir().unwrap();
+ let original_dir = env::current_dir().unwrap();
+
+ let _ = cd(CommandContext::new(vec![dir_a
+ .path()
+ .to_string_lossy()
+ .to_string()]))
+ .await;
+ assert_eq!(
+ normalize(Path::new(&env::var("PWD").unwrap())),
+ normalize(dir_a.path())
+ );
+
+ let _ = cd(CommandContext::new(vec![dir_b
+ .path()
+ .to_string_lossy()
+ .to_string()]))
+ .await;
+ assert_eq!(
+ normalize(Path::new(&env::var("PWD").unwrap())),
+ normalize(dir_b.path())
+ );
+ assert_eq!(
+ normalize(Path::new(&env::var("OLDPWD").unwrap())),
+ normalize(dir_a.path())
+ );
+
+ env::set_current_dir(original_dir).unwrap();
+ }
+
+ #[tokio::test]
+ async fn test_cd_relative_resolves_against_cwd_option() {
+ let _guard = CD_TEST_LOCK.lock().await;
+ let temp = tempdir().unwrap();
+ std::fs::create_dir(temp.path().join("sub")).unwrap();
+ let original_dir = env::current_dir().unwrap();
+
+ let mut ctx = CommandContext::new(vec!["sub".to_string()]);
+ ctx.cwd = Some(temp.path().to_path_buf());
+ let result = cd(ctx).await;
+ assert!(result.is_success());
+ assert_eq!(
+ normalize(&env::current_dir().unwrap()),
+ normalize(&temp.path().join("sub"))
+ );
+
+ env::set_current_dir(original_dir).unwrap();
}
}