
# bash

`bash` contributes a shell to the [terminal](/extensions/terminal):
[just-bash](https://www.npmjs.com/package/just-bash), a real but partial bash interpreter,
running in the page rather than on a server. It needs `terminal` alongside it to have somewhere
to appear.

Behind `node`, `npm`, `npx`, `pnpm` and `yarn` is a [WebContainer](https://webcontainers.io) —
node itself, in the page, with a real `node_modules` and dev servers — wherever the page is
cross-origin isolated. Where it isn't, `node` is QuickJS over the workbench's own files and there
is no package manager. One extension either way, and the same shell either way: what changes is
how much of node you get.

```ts
import { Workbench, FileSystem } from "codelet/workbench";
import { terminal } from "codelet/extensions/terminal";
import { bash } from "codelet/extensions/bash";

const workbench = new Workbench({
  parent: document.getElementById("app")!,
  fs: new FileSystem({ "/README.md": "# Hello" }),
  extensions: [terminal(), bash()],
});
```

Nothing is bundled: just-bash is fetched from a CDN by the first tab, and the engine behind `node`
by the first script — a workbench where nobody opens a terminal downloads none of it.

## The two headers

WebContainer needs a `SharedArrayBuffer`, and a page only gets one cross-origin isolated. Serve
the document with:

```http
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
```

With both, `node` is node and `npm install` installs. Without them you still get a terminal, a
shell and a `node` — QuickJS, over the tree — and the reason the rest is missing is one line in
the Logs panel. So `bash()` is safe to mount unconditionally, including on pages that will never
be isolated.

`require-corp` is the value to serve. `credentialless` is the gentler one on paper — a
cross-origin subresource is fetched without credentials rather than turned away — but only
Chromium and Firefox know the token, and a browser that does not reads it as `unsafe-none`: a
credentialless page is every Safari, and every browser on iOS, with no machine in it. What
`require-corp` asks of a subresource is a `Cross-Origin-Resource-Policy` of its own **or** a
CORS-mode request that passes, and everything codelet fetches — the SDK, xterm, the language
services — is a module `import()` or a `fetch()`, both CORS mode. Your own assets are what to
look at: an `<img>`, a stylesheet or a `<script>` from another origin is a no-cors load, and
wants `Cross-Origin-Resource-Policy: cross-origin` from wherever it is served, or a `crossorigin`
attribute on the tag.

::warning
**Safari asks for the header anyway, inside a worker.** WebKit reads that "or" as an "and" for
anything a worker imports or fetches, whatever the request mode. The language servers and QuickJS
`node` load their modules from inside a worker, and esm.sh sends no
`Cross-Origin-Resource-Policy` — so an isolated page on Safari, and on every browser on iOS, keeps
its machine and quietly loses all of them. Chromium and Firefox take the CORS request and are
unaffected, which is what makes it easy to miss.

The way out is to stop the load being cross-origin at all: serve those modules from your own
origin, where the check never runs. Point `cdn` at it — on `typescript()`, `css()`, `html()`,
`json()`, `markdown()`, and on `bash`'s `quickjs` — with an **absolute** URL, since these are read
inside workers built from a blob and a bare `/libs` has no base to resolve against:

```ts
const CDN = `${typeof location === "undefined" ? "" : location.origin}/libs`;
```

codelet.sh serves the nine packages involved at `/libs`. The tree is built by `apps/cdn` in this
repository, written at the paths esm.sh answers, and copied into the app's `public/`, so nothing
but the origin changes. Serving it cross-origin works too, with
`Cross-Origin-Resource-Policy: cross-origin` on every response — but that is a header to get right
where same-origin is a question that is never asked.
::

Then pass the same value on — `bash({ container: { coep: "require-corp" } })`. A dev server's page
is a document nested in this one, and an isolated page frames only a child isolated the same way;
the SDK serves its own origins to match whatever it is told here. It is fixed at the first boot,
whatever a later one asks for.

::note
The first `npm` on a page asks the reader to agree to
[StackBlitz's terms](https://stackblitz.com/terms-of-service), since WebContainer is theirs and
commercial production use needs a licence from them. The answer is remembered, and a host that has
already agreed on their reader's behalf passes `container: { terms: false }`.
::

## Browsers

Chromium is where WebContainer is at its best, and Safari 16.4 and up runs it — desktop, iOS and
iPadOS alike, StackBlitz calling that beta — since `SharedArrayBuffer` is the thing it was waiting
on. Two caveats there are the platform's rather than this extension's: a page on a phone has a
memory ceiling that a large `npm install` is what meets first, and a reload on iOS does not
reliably free the machine the last one left. Firefox isolates and boots; a preview served out of
the container is the part to check there.

## Options

::details-block{summary="Every `bash()` option"}

| Option      | Type                                  | Default                                             | Description                                                                                                             |
| ----------- | ------------------------------------- | --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| `name`      | `string`                              | `"bash"`                                            | What the profile is called in the menu and on the tab.                                                                  |
| `workspace` | `boolean`                             | `true`                                              | Whether the shell is a shell over the workbench's files. `false` gives it only its own `/tmp`.                          |
| `mount`     | `string`                              | `"/home/workspace"`                                 | Where the workbench's files are mounted, and where the shell opens when it has a workspace.                             |
| `files`     | `Record<string, string>`              | —                                                   | What the in-memory filesystem around the mount starts with, by absolute path.                                           |
| `env`       | `Record<string, string>`              | `HOSTNAME` the page's host, `HOME` the mount        | Starting environment variables, over just-bash's own.                                                                   |
| `cwd`       | `string`                              | `mount`, or just-bash's own `/` without a workspace | Where the shell opens.                                                                                                  |
| `prompt`    | `string \| ((cwd: string) => string)` | `\u@\h:\w\|⇒`                                       | What the prompt says: a `PS1`, or a function of the working directory. See [The prompt](#the-prompt).                   |
| `network`   | `ShellNetwork \| false`               | every URL and every method                          | What `curl`, `wget` and `ping` may reach. See [Network](#network).                                                      |
| `history`   | `string \| false`                     | `"codelet.just-bash.history"`                       | The `localStorage` key command history is kept under. `false` turns off persistence.                                    |
| `container` | `BashContainerOptions \| false`       | on wherever the page is isolated                    | The machine `node` and the package managers run on. See below.                                                          |
| `quickjs`   | `boolean \| NodeOptions`              | `true`                                              | The `node` a page with no machine gets, and how it handles `.ts`/`.tsx`/`.jsx`. See [node](#running-scripts-with-node). |
| `cdn`       | `string`                              | `"https://esm.sh"`                                  | Where just-bash, quickjs-wasi and the WebContainer SDK are fetched from.                                                |

::

::details-block{summary="Every `container` option"}

| Option        | Type                                           | Default                                                  | Description                                                                       |
| ------------- | ---------------------------------------------- | -------------------------------------------------------- | --------------------------------------------------------------------------------- |
| `terms`       | `boolean`                                      | `true`                                                   | Whether the reader is asked to agree to StackBlitz's terms before the first boot. |
| `exclude`     | `readonly string[]`                            | `[".git"]`                                               | Directory names never mirrored, matched at any depth.                             |
| `commands`    | `readonly string[]`                            | `jsh`, `node`, `npm`, `npx`, `pnpm`, `yarn`, `xxd`, `ps` | Which command names are spawned on the machine rather than run in the page.       |
| `cdn`         | `string`                                       | `"https://esm.sh"`                                       | Where `@webcontainer/api` is fetched from.                                        |
| `coep`        | `"require-corp" \| "credentialless" \| "none"` | the SDK's own                                            | Which COEP the page is served with, so the SDK's own origins are framed by it.    |
| `workdirName` | `string`                                       | the SDK's own                                            | Names the working directory: `/home/<workdirName>`.                               |

::

`container: false` is a page that could have booted one and would rather not: the shell is the
same, and `node` is QuickJS.

## Try it

Type `ls` in the terminal: it lists the workbench's own files, mounted at `/home/workspace`. Run
`cat greet.ts`, then edit it from the shell with `sed -i 's/hi/hey/' greet.ts` — the `greet.ts`
tab above redraws with the change. Run `node greet.ts` to see it execute. xterm, just-bash and
whatever runs the script each load on first use, so give it a moment.

::codelet-playground{mode="workbench" extensions="terminal" view="terminal" active="greet.ts" height="480"}

```ts [greet.ts]
export const greet = (name: string) => `hi ${name}`;

console.log(greet("codelet"));
```

```md [README.md]
# bash demo

A shell over these files.
```

::

## Files

By default the shell is a shell over the workbench's own files, mounted at `/home/workspace` (or
wherever `mount` says). `ls` is the tree the explorer draws, and `sed -i` is a file the editor
redraws — writes go through `workspace.fs`, the same as a save from the editor.

The mount is also `$HOME`, so `~` is the tree, a bare `cd` comes back to it, and the prompt writes
that one character rather than the whole path. The name is `/home/`-rooted because a WebContainer
can only ever put its working directory there, and matching the two is what keeps a path node
prints — a stack trace, an `npm` error — a path the shell can `cat`.

The mount sits inside a small in-memory filesystem rather than being the shell's whole `/`. A
`/tmp` the shell writes for itself (a heredoc, a pipeline's scratch file) stays there, outside
the mount, and never touches the workbench.

`workspace: false` gives the shell only that in-memory filesystem, with no mount at all: a demo
shell with nothing of the reader's in it.

**The machine's filesystem is the same tree again.** The workbench's files are handed over when
the container boots, and the two are kept in step after that: what you edit is written into the
container, and what a command writes is written back into the tree. So `npm init -y` puts a
`package.json` in the explorer, and a file you save in the editor is the file the dev server
rebuilds. Both directions are debounced, and only `exclude` (`.git`) is never carried at all.

`node_modules` is the one directory with a rule of its own: an install comes back as the
`package.json` and `.d.ts` files it wrote and nothing else, so imports in the editor resolve
against the packages really installed without a hundred thousand files landing in the workbench's
memory. The JavaScript beside them stays on the container's disk, where the only thing that runs
it is, and it never goes the other way.

Two things to know. A command only runs on the machine from inside the mount — the shell's own
`/tmp` is not a place over there. And an empty directory does not reach the container until
something is written into it.

## Commands

Beyond just-bash's own set, this registers:

- **`node`, `npm`, `npx`, `pnpm`, `yarn`** — processes on the machine; see
  [Running scripts with node](#running-scripts-with-node) for what a page without one gets.
- **`xxd`, `ps`** — the machine's own, and only where there is one: just-bash carries neither, and
  a page has no processes to list. `od` and `strings` are its nearest to the first.
- **`jsh`** — the machine's own shell, for looking at the container as itself: `jsh` on its own
  is a prompt inside the tab (`env`, `which npm`, `ls ~`), and `jsh -c '<line>'` runs one line and
  comes back. `exit` or `^D` ends it; `^C` belongs to it rather than to the tab. Only there when
  there is a machine.
- **`open`, `code`** — open a file in a workbench tab. Given a directory, it reveals it in the
  explorer instead, since a directory has no tab. Given a URL, it opens a browser tab, which
  needs no host at all.
- **`vi`, `vim`, `nano`** — the same as `open`, after creating the file first if it isn't there
  yet, since that's what opening an editor on a new path means.
- **`realpath`** — resolve a path.
- **`yes`** — repeat a word or `y`. Bounded rather than infinite (`-n`, default 1000, capped at
  100,000): a command here answers with its whole output at once, so an unbounded one would hang
  the tab.
- **`dd`** — copy bytes between a file, stdin and stdout, with `bs`, `count`, `skip` and `seek`.
- **`link`, `unlink`** — a hard link and its removal. The workbench's files support neither
  linking nor symlinks, so `link` fails with the filesystem's own error.
- **`uname`** — reports `just-bash` as the kernel name, the host name, the pinned
  just-bash version, and `codelet` as the OS. `-m` is `navigator.platform`, the one field a
  browser actually answers for the machine.
- **`hostname`** — the page's own host, `-s` for the part before the first dot and `-d` for the
  rest. A page opened at an address rather than a name — `192.168.1.5`, `[::1]` — has no domain to
  take off, so `-s` is the whole of it and `-d` is empty. The shell starts with `HOSTNAME` set to `location.hostname` rather than just-bash's
  `localhost`, and this, `uname -n` and `\h` in the prompt all read that variable — so
  `export HOSTNAME=box`, or an `env` given to `openBash`, moves all three at once. Setting one as
  an argument is refused, as a real `hostname NAME` is for anyone but root.
- **`id`** — the one user there is, uid and gid `1000`.
- **`uptime`** — how long the page has been open. No load averages: nothing in a page measures
  one.
- **`watch`** — reruns a command on an interval. Bounded passes (`-c`, default 3, capped at 20)
  rather than running until stopped, and no screen clearing between them.
- **`sudo`** — runs the rest of the line as the one user there is. No privileges to raise, so it
  exists only so a copied command line still runs.
- **`wget`, `ping`** — only registered when `network` is configured; see [Network](#network).
- **`netstat`, `ss`, `lsof -i`, `nc -z`, `fuser`** — what is listening, in each command's own
  columns. They read the workbench's port registry — the same list the
  [Ports](/extensions/ports) tab draws — so `netstat -tlnp` finds a dev server whether this shell
  started it, a remote host published it, or a live-share guest did. Nothing in a page holds a
  port, so the pid, fd and queue columns are `-` or zero rather than a made-up number, and a
  listing ends in a line per port saying where it's really reachable when that isn't
  `localhost:<port>`. `fuser -k 3000/tcp` ends the server where the port could be traced back to
  a running command, and names `pkill -f` where it couldn't. Anything a page genuinely can't do —
  a routing table, a unix socket, `nc` opening a connection — says so and names what works here.

## Network

`curl` (just-bash's own), plus `wget` and `ping`, reach whatever the page's CORS already lets it
reach. That's the whole answer to what a browser can fetch, so `network` only ever narrows it,
never widens it. It says nothing about the machine: a process in the container has the network
the container has, which is how `npm install` reaches the registry.

`network: false` removes all three commands: Tab completion doesn't offer them and they aren't in
the shell at all.

An object is an allow-list:

| Option                               | Type                                                                         | Default       | Description                                                                                                                                                           |
| ------------------------------------ | ---------------------------------------------------------------------------- | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `allowedUrlPrefixes`                 | `(string \| { url: string; transform?: RequestTransform[] })[]`              | —             | Origins requests may reach, optionally with a path prefix, e.g. `"https://api.example.com/v1/"`. Nothing else is reachable.                                           |
| `allowedMethods`                     | `("GET" \| "HEAD" \| "POST" \| "PUT" \| "DELETE" \| "PATCH" \| "OPTIONS")[]` | `GET`, `HEAD` | Methods a request may use.                                                                                                                                            |
| `dangerouslyAllowFullInternetAccess` | `boolean`                                                                    | —             | Every URL and every method.                                                                                                                                           |
| `maxRedirects`                       | `number`                                                                     | —             | How many redirects a request follows.                                                                                                                                 |
| `timeoutMs`                          | `number`                                                                     | —             | How long a request waits before it's cut off.                                                                                                                         |
| `maxResponseSize`                    | `number`                                                                     | —             | The largest response body allowed.                                                                                                                                    |
| `denyPrivateRanges`                  | `boolean`                                                                    | `false`       | just-bash's own SSRF guard. It works by resolving a host with node's `dns`, which doesn't exist in a browser, so it's off here regardless of just-bash's own default. |

::warning
`dangerouslyAllowFullInternetAccess` removes the allow-list entirely: every origin and every
method, limited only by what the page's own CORS already allows. This is what `bash()` defaults to
when you don't pass `network` at all.
::

`ping` has no ICMP to work with in a browser. It's a timed HTTP GET through the same allow-list as
`curl`, and its output says so rather than claiming to send packets.

## Running scripts with node

**With a machine**, `node` is node: `node --watch server.js`, `npm install`, `pnpm dlx`, a test
runner, whatever is on npm. The machine boots on the first one of these commands and is shared by
every tab after that, as one real box would be — the second tab is a second shell over the same
`node_modules` and the same running processes.

Output goes where a terminal's output goes: straight into the tab as it arrives, and what you type
while it runs reaches it — so `npm init` can ask you a question, and `jsh` is a shell you can work
in. A line that reads that output instead — a pipe, a redirect, a `$(…)` — gets the whole of it as
the command's result, so `npm ls | grep react` works as it reads. `^C` kills the process, except
inside `jsh`, where it is the shell's own key.

**Without one**, `node` runs a `.js` file in QuickJS, in a worker of its own beside the page. A
worker per run rather than one kept alive is what makes `^C` a `terminate`, and why a run leaves
nothing behind for the next one. `import` and `require` resolve against the workbench's own files
— the graph of everything a script imports is read and resolved before the run starts, since
QuickJS asks for a module synchronously and reading the workbench's files is not.

That engine only reads `.js`. `.ts`, `.tsx` and `.jsx` need a `transform` to turn them into
something QuickJS can read:

```ts
import { bash, typescript } from "codelet/extensions/bash";

bash({ quickjs: { transform: typescript() } });
```

`typescript()` is a type stripper built on [sucrase](https://github.com/alangpierce/sucrase),
fetched off the same CDN the first time a `.ts` file is run. It strips types rather than
compiling: nothing is lowered, ESM is left as ESM, and JSX compiles to `React.createElement` by
default — a name, not a dependency, since a script under QuickJS has no `node_modules` to find a
real React in. A `namespace` is dropped rather than emitted, and a decorator is passed through
unchanged, which QuickJS then refuses. None of it reaches the machine's own `node`, which reads a
`.ts` or refuses one exactly as the node version in the container does.

::note
Both `codelet/extensions/bash` and `codelet/extensions/lsp/typescript` export a function named
`typescript`, for unrelated purposes — one strips types for QuickJS, the other runs the TypeScript
language service. Import both in the same file and alias one.
::

What QuickJS is _not_: there's no `node:fs`, no sockets, and no `node_modules`. It has `console`,
`process`, the timers, `import` and `require`, run over the workbench's own files and nothing
else. It is also what `node` falls back to on an isolated page where the machine did not start —
the terms declined, or StackBlitz's CDN unreachable — so a script runs either way. `npm` cannot
fall back anywhere and says what went wrong instead.

## Servers

Start a dev server and it opens in a window floating over the workbench on its own — no click
needed, and your file keeps the whole pane. Drag the window by its bar, size it from its corner,
or put it back in the pane with the dock button beside its close. The address is not the
`localhost:3000` the command printed: WebContainer serves it from an origin of its own, and that
window shows the real one. A restart on the same port reloads it in place rather than opening a
second, without throwing the window to the front or taking the cursor out of what you're editing.
On a narrow screen there's nowhere to put a window, so the page opens as a tab in the pane
instead.

Closed it? Run `bash: Open Server` from the palette to bring it back — a quick pick if more than
one server is running, otherwise straight to the one there is.

That window is a real cross-origin frame, not a preview panel: codelet's webviews are sandboxed
without same-origin access, which would leave a dev server running in an opaque origin with its
storage and its hot-reload socket refused. Its bar has a button to open the same address in a
genuine browser tab too, for a login or a popup that refuses to run inside a frame at all.

## The prompt

The prompt says who and where — `user@localhost:~|⇒` — with the name magenta, the host
yellow, the path cyan and the punctuation red. It's a zsh prompt's shape written in bash's escapes,
painted out of the ANSI eight rather than colours of its own, so the terminal's theme is what they
actually come out as. The red bar is a separator with nothing after it yet: it's where a branch
goes in the prompt this borrows from.

`prompt` names another — a `PS1` string, read the way bash reads one:

```ts
bash({ prompt: "\\u@\\h:\\w\\$ " }); // user@localhost:~$
bash({ prompt: "\\[\\e[35m\\]\\W\\[\\e[0m\\] ➜ " }); // a magenta directory name
```

`\w` is where the shell is (`\W` just the last part of it, `$HOME` said as `~` in both), `\u` and
`\h` who and where it is, `\s` and `\v` what it is, `\d`/`\t`/`\T`/`\@`/`\A` the date and the time,
`\e` an escape and `\[`/`\]` the brackets around what does not print. `\$` is always `$`, there
being no root here. Four of bash's escapes want a machine underneath — `\j` a job count, `\l` a
tty, `\#` and `\!` a numbered command — and are left as they were written, which is what bash does
with any escape it does not know. Nothing but a backslash is expanded, so a `$PWD` in a `PS1` is
those four characters rather than the path.

A function is the same job for a prompt easier said in code than in escapes, and is handed the
working directory:

```ts
bash({ prompt: (cwd) => `\u001B[2m${cwd.split("/").pop()}\u001B[0m λ ` });
```

**Whoever is typing has the last word.** `PS1` is an ordinary variable here, so `export PS1='\W \$ '`
in the terminal is what the next prompt is, and `prompt` is the prompt for a reader who has
exported none. `unset PS1` gives it back. It's read fresh for every prompt, so nothing has to be
restarted, and a `PS1` in `env` starts the shell with it.

A `\n` gives a prompt of two rows. It works, with one cost: line editing rubs out the row it's on,
so an arrow key redraws under the last row rather than over the whole prompt.

## History

Lines typed into the shell are kept in `localStorage`, under the key `history` names — one
shell's own (`"codelet.just-bash.history"`) unless you name another, or `false` for a shell whose
memory is only its own tab. Several tabs sharing a key share one history, the way several shells
on the same machine share a `.bash_history`: each loads it on open, and whichever tab wrote most
recently is what the next one sees.

:read-more{to="/extensions/terminal"}
