> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://cla-demo.postmansolutions.com/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://cla-demo.postmansolutions.com/_mcp/server.

# Node without Node Installed

We use Node and NPM from a designated Docker image **to create close parity between local development, pre-production, and production environments**. Local installations are strongly discouraged to prevent version drift and promote reproducibility.

To support this workflow, we use a set of wrapper scripts that allow developers to run common Node and NPM commands *as if they were installed locally*, without directly using Node or NPM on their machine.

---

## 📁 `resolve_parent_directory.bash`: Canonical Path Resolution

This script identifies the **real, absolute parent directory** of a target file or symlink—including resolving any symbolic links in its path. It’s primarily used by wrapper scripts to find supporting files relative to the physical location of the script being executed.

**Behavior:**

* Accepts a file path as input (typically `$0`)
* Resolves symbolic links recursively
* Normalizes relative paths to absolute
* Outputs the physical parent directory path

This ensures that related assets (config, shared functions, templates) can be found reliably, regardless of how or where a script is invoked.

**Suggested filename:** `resolve_parent_directory.bash`\
**Suggested location:** `$HOME/etc/resolve_parent_directory.bash`\
**Usage in other scripts:** Source it directly using an absolute path:

```bash
source "$HOME/etc/resolve_parent_directory.bash"
```

**Script:**

```bash
resolve_parent_directory() {
  local source_path="$1"

  if [[ -z "$source_path" ]]; then
    echo "Usage: resolve_real_parent_dir <path>" >&2
    return 1
  fi

  if [[ ! -e "$source_path" && ! -L "$source_path" ]]; then
    echo "Error: '$source_path' does not exist." >&2
    return 1
  fi

  # Normalize "." and similar to full path before resolving
  source_path="$(cd -P "$(dirname "$source_path")" >/dev/null 2>&1 && pwd)/$(basename "$source_path")"

  # Resolve symlinks
  while [[ -L "$source_path" ]]; do
    local dir
    dir="$(cd -P "$(dirname "$source_path")" >/dev/null 2>&1 && pwd)"
    source_path="$(readlink "$source_path")"
    [[ "$source_path" != /* ]] && source_path="$dir/$source_path"
  done

  local real_dir
  real_dir="$(cd -P "$(dirname "$source_path")" >/dev/null 2>&1 && pwd)" || return 1
  echo "$real_dir"
}
```

---

## 🛠️ Preset Runner Scripts: Handling `-it` for Docker CLI

We provide three wrapper presets, each with a different approach to Docker's interactive terminal (`-it`) behavior. These scripts use their filename (`$0`) to determine which containerized tool (e.g., `npm`, `npx`, `node`) to invoke.

All presets rely on a shared runtime script (`run-cli.sh`) and the `resolve_parent_directory` function to locate related files. **To ensure consistent behavior, all presets and supporting scripts should be stored in the same directory**, such as:

**Suggested storage directory:**\
`$HOME/etc/`

This structure ensures symlinked tools (like `npm` or `node`) can resolve everything they need at runtime.

---

### 🧩 `autonamedInteractive`

Always uses `-it` and is ideal for interactive tools (like `npx` or shells).

**Suggested filename:** `autonamedInteractive`\
**Storage directory:** `$HOME/etc/`\
**Usage:** Link to commands that always expect a TTY

```bash
#!/bin/bash

. "$HOME/etc/resolve_parent_directory.bash"

IMAGE_NAME="cliftonlarsonallen.azurecr.io/library/node:20-alpine"
IMAGE_UID=$(id -u)
IMAGE_GID=$(id -g)
DOCKER_INTERACTIVE_TTY="-it"
CMD_NAME="$(basename "$0")"

. "$(resolve_parent_directory "$0")"/run-cli.sh
```

---

### 🧩 `autonamedInteractiveWith0Args`

Only includes `-it` when **no arguments** are passed; useful for tools like `npm`.

**Suggested filename:** `autonamedInteractiveWith0Args`\
**Storage directory:** `$HOME/etc/`\
**Usage:** Link to flexible tools that switch modes depending on input

```bash
#!/bin/bash

. "$HOME/etc/resolve_parent_directory.bash"

IMAGE_NAME="cliftonlarsonallen.azurecr.io/library/node:20-alpine"
IMAGE_UID=$(id -u)
IMAGE_GID=$(id -g)
DOCKER_INTERACTIVE_TTY=$([ $# -eq 0 ] && echo "-it" || echo "")
CMD_NAME="$(basename "$0")"

. "$(resolve_parent_directory "$0")"/run-cli.sh
```

---

### 🧩 `autonamedNoninteractive`

Never includes `-it`; good for `node` or other non-interactive tasks.

**Suggested filename:** `autonamedNoninteractive`\
**Storage directory:** `$HOME/etc/`\
**Usage:** Link to commands like `node`, `tsc`, or `eslint`

```bash
#!/bin/bash

. "$HOME/etc/resolve_parent_directory.bash"

IMAGE_NAME="cliftonlarsonallen.azurecr.io/library/node:20-alpine"
IMAGE_UID=$(id -u)
IMAGE_GID=$(id -g)
DOCKER_INTERACTIVE_TTY=""
CMD_NAME="$(basename "$0")"

. "$(resolve_parent_directory "$0")"/run-cli.sh
```

---

## 🧱 `run-cli.sh`: Shared Container Execution Logic

This script contains the shared logic used by all preset wrappers. It centralizes the logic for container execution, mounts, environment variables, and image configuration—allowing wrapper scripts like `autonamedInteractive` to stay minimal and maintainable.

**Suggested filename:** `run-cli.sh`\
**Suggested location:** `$HOME/etc/` (stored alongside your preset scripts)

**Script:**

```bash
#!/bin/bash

EMPTY_HOME="$(mktemp -d)"
NPMRC_PATH="$HOME/.npmrc"
START_DIR=$(pwd)
DOCKER_MOUNTS=("-v" "$EMPTY_HOME:$HOME" "-v" "$HOME/.gitconfig:$HOME/.gitconfig" "-v" "$HOME/.npm:$HOME/.npm" "-v" "$HOME/.vscode-server:$HOME/.vscode-server" "-v" "$HOME/.ssh:$HOME/.ssh" "-v" "$HOME/src:$HOME/src" "-v" "$HOME/doc:$HOME/doc" )

IMAGE_NAME="jck-node-20"

# Check if image exists
if ! docker image inspect "$IMAGE_NAME" > /dev/null 2>&1; then
        ( cd "$HOME/etc/dockerizedTools" && docker build -t "$IMAGE_NAME" . && echo "Image '$IMAGE_NAME' built and cached locally." )
fi

if [ -z "$IMAGE_UID" ] ; then
  echo "Error: Could not determine UID to use for the image in the container."
  exit 1
fi

if [ -z "$IMAGE_GID" ]; then
  echo "Error: Could not determine GID to use for the image in the container."
  exit 1
fi

if [ ! -e "$NPMRC_PATH" ]; then
  echo "# This file was auto-created by a script. If this comment is the only content, it is safe to delete." > "$NPMRC_PATH"
fi
if [ -d "$NPMRC_PATH" ]; then
  echo "Error: $NPMRC_PATH is a directory, not a file." >&2
  exit 1
elif [ ! -r "$NPMRC_PATH" ]; then
  echo "Error: $NPMRC_PATH exists but is not readable." >&2
  exit 1
fi
DOCKER_MOUNTS+=("-v" "$NPMRC_PATH:$NPMRC_PATH")

touch "$HOME"/.node_repl_history
DOCKER_MOUNTS+=("-v" "$HOME/.node_repl_history:$HOME/.node_repl_history")

DOCKER_PATH="$START_DIR/node_modules/.bin:$HOME/.npm/jckFakeGlobal/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"

export CONTAINER_VITEST_WS_ADDRESS=$(echo "$VITEST_WS_ADDRESS" | sed -e 's%ws://localhost%ws://host.docker.internal%')

# Comment or remove this part after you have a working setup with the details you like
echo docker run --init --rm $DOCKER_INTERACTIVE_TTY \
  -u "$IMAGE_UID:$IMAGE_GID" \
  --add-host=host.docker.internal:host-gateway \
  --add-host=azurite:host-gateway \
  --add-host=storage_containers:host-gateway \
  --add-host=db:host-gateway \
  --add-host=sql_server:host-gateway \
  --add-host=servicebus:host-gateway \
  --add-host=service_bus:host-gateway \
  "${DOCKER_MOUNTS[@]}" \
  -w "$START_DIR" \
  -e AzureWebJobsScriptRoot="$START_DIR" \
  -e AzureWebJobsStorage=${AzureWebJobsStorage} \
  -e BLOB_CONTAINER_NAME="${BLOB_CONTAINER_NAME}" \
  -e HOME="$HOME" \
  -e PATH="${DOCKER_PATH}" \
  -e FORCE_COLOR=1 \
  -e NODE_ENV=development \
  -e LOG_LEVEL=info \
  -e NPM_TOKEN=$NPM_TOKEN \
  -e VITEST_WS_ADDRESS="$CONTAINER_VITEST_WS_ADDRESS" \
  -e COPILOT_PREFERENCES_REPO="$COPILOT_PREFERENCES_REPO" \
  -e GIT_SSH_COMMAND="ssh -i $HOME/.ssh/id_rsa -o UserKnownHostsFile=$HOME/.ssh/known_hosts_container -o StrictHostKeyChecking=yes" \
  $EXTRA_DOCKER_ARGS \
  "$IMAGE_NAME" \
  "$CMD_NAME" "$@"

docker run --init --rm $DOCKER_INTERACTIVE_TTY \
  -u "$IMAGE_UID:$IMAGE_GID" \
  --add-host=host.docker.internal:host-gateway \
  --add-host=azurite:host-gateway \
  --add-host=storage_containers:host-gateway \
  --add-host=db:host-gateway \
  --add-host=sql_server:host-gateway \
  --add-host=servicebus:host-gateway \
  --add-host=service_bus:host-gateway \
  "${DOCKER_MOUNTS[@]}" \
  -w "$START_DIR" \
  -e AzureWebJobsScriptRoot="$START_DIR" \
  -e AzureWebJobsStorage=${AzureWebJobsStorage} \
  -e BLOB_CONTAINER_NAME="${BLOB_CONTAINER_NAME}" \
  -e HOME="$HOME" \
  -e PATH="${DOCKER_PATH}" \
  -e FORCE_COLOR=1 \
  -e NODE_ENV=development \
  -e LOG_LEVEL=info \
  -e NPM_TOKEN=$NPM_TOKEN \
  -e VITEST_WS_ADDRESS="$CONTAINER_VITEST_WS_ADDRESS" \
  -e COPILOT_PREFERENCES_REPO="$COPILOT_PREFERENCES_REPO" \
  -e GIT_SSH_COMMAND="ssh -i $HOME/.ssh/id_rsa -o UserKnownHostsFile=$HOME/.ssh/known_hosts_container -o StrictHostKeyChecking=yes" \
  $EXTRA_DOCKER_ARGS \
  "$IMAGE_NAME" \
  "$CMD_NAME" "$@"
```

The run-cli.sh script is doing a lot of the heavy lifting for us.  It first determines whether we have a complete enough environment.  Once it looks like the needed support tools and directories are there it makes sure it knows who we are so that it never runs as root but only our personal user and group.  Then it does a few things to make sure we have a decent environment for running npm and node by making sure we can find the cache folders and the config files for it.  It then makes sure we use a decent and safe path in the container that includes the needed tool locations.  Then it configures the web socket address needed by vitest.

After that it runs the big whopper of a docker command to start the container with the proper image and all the mounts we needed and the right user and a bunch of environment settings we need in order for everything to work.  Keep in mind this is a work in progress so we might be missing something if nobody has tried the tool and it has new needs.

As a general fudge for when we are experimenting, I added support for an `EXTRA_DOCKER_ARGS` environment variable.  You can put things in there like EXTRA\_DOCKER\_ARGS="-p 9229:9229" if you need to open the debug port for example.

For those who want a more complicated list of what that docker command does for reference:

* We will run a command using this image and a new container.
* DOCKER\_INTERACTIVE\_TTY says to use -it or not to redirect terminal lines for an interactive run.
* We run as ourselves to avoid problems running as root.  Doesn't work with every image.
* Add a bunch of names to the container's DNS resolver which all point to the outgoing gateway
* Apply the various mounts we need as determined in the earlier scripting
* Start us in the START\_DIR which is usually the current directory in the container & host
* Sets a couple of things that are needed to run an Azure function locally
* A hack that lets me run my function app but is porbably unneeded outside of that
* Makes our HOME variable in the container the same as our HOME on linux
* Sets the PATH based on what we decided so far
* Attempts to get color output when possible
* Expects to always work ONLY for NODE's dev environment
* Forces the log level to info
* Passes the NPM token through to the container so we can use it for npm install
* Sets a variable I need to make vitest work correctly in vscode
* Sets a variable that might only be for Jason that says where the copilot preferences are
* Sets a git command hack that lets me avoid problems with always thinking every machine is new
* Anything from the EXTRA\_DOCKER\_ARGS we set (like for the debug port maybe)
* Use the image we were told to use from the environment
* Run the command we got from the environment and send it all of the args we got passed

---

## 🔗 Creating Linked Commands in Your `~/bin`

To use these wrapper presets easily from anywhere, we recommend setting up a personal `~/bin` directory and adding it to your shell’s `PATH`.

### Step 1: Create the bin directory (if it doesn’t exist)

```bash
mkdir -p "$HOME/bin"
```

### Step 2: Add it to your `PATH`

Edit your shell’s startup script (e.g., `~/.bash_profile`, `~/.bashrc`, or `~/.profile`) and add:

```bash
export PATH="$HOME/bin:$PATH"
```

Then either restart your terminal or reload the config:

```bash
source ~/.bashrc  # or similar for your shell
```

### Step 3: Create symlinks to the presets

From within the `~/bin` directory, link tool names to the appropriate preset script in `$HOME/etc/`:

```bash
cd "$HOME/bin"
ln -s ../etc/autonamedNoninteractive node
ln -s ../etc/autonamedInteractiveWith0Args npm
ln -s ../etc/autonamedInteractive npx
ln -s ../etc/autonamedInteractive eslint
```

> **Note:** Tools like `eslint` will only work if they’re already installed in your project’s `node_modules`.

## 🧹 Removing `nvm`, Node.js, and npm from Your Local Machine

**Once you’ve adopted the container-based Node workflow, uninstall all local versions of Node.js, npm, and `nvm`.** Retaining them creates ambiguity in execution paths and increases the risk of bypassing the standardized container environment.

> ⚠️ This section assumes Node and npm were installed via the [nvm](https://github.com/nvm-sh/nvm) script (e.g., `curl ... | bash`). If other installation methods were used (e.g., Homebrew, apt), additional steps may apply.

### Step 1: Uninstall all Node.js versions

```bash
nvm uninstall --lts
nvm uninstall node
nvm ls   # Confirm nothing is listed
```

Repeat `nvm uninstall <version>` for any additional listed versions.

### Step 2: Remove `nvm` itself

```bash
rm -rf "$NVM_DIR"
```

Typically, `NVM_DIR` is `~/.nvm`, but run `echo $NVM_DIR` to confirm.

### Step 3: Remove `nvm` references from shell startup scripts

Edit the appropriate file for your shell (`~/.bashrc`, `~/.bash_profile`, `~/.zshrc`, etc.) and remove lines like:

```bash
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
```

Then restart your terminal or run `source ~/.bashrc` (or equivalent) to apply changes.