Skip to content
8 changes: 8 additions & 0 deletions experiments/cd-edge.mjs
Original file line number Diff line number Diff line change
@@ -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());
31 changes: 31 additions & 0 deletions experiments/cd-sh-comparison.mjs
Original file line number Diff line number Diff line change
@@ -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');
6 changes: 6 additions & 0 deletions experiments/env-expand.mjs
Original file line number Diff line number Diff line change
@@ -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()));
}
8 changes: 8 additions & 0 deletions experiments/env2.mjs
Original file line number Diff line number Diff line change
@@ -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()));
12 changes: 12 additions & 0 deletions js/.changeset/cd-sh-compatibility.md
Original file line number Diff line number Diff line change
@@ -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.
75 changes: 75 additions & 0 deletions js/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
50 changes: 50 additions & 0 deletions js/examples/cd-cwd-sh-translation.mjs
Original file line number Diff line number Diff line change
@@ -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 });
}
62 changes: 56 additions & 6 deletions js/src/commands/$.cd.mjs
Original file line number Diff line number Diff line change
@@ -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 <dir>` -> change to <dir> (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',
Expand Down
12 changes: 9 additions & 3 deletions js/tests/cd-virtual-command.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading