Skip to content

cli: mount extra host directories into the container - #386

Open
woltspace-jerpint[bot] wants to merge 4 commits into
mainfrom
feat/extra-mounts
Open

cli: mount extra host directories into the container#386
woltspace-jerpint[bot] wants to merge 4 commits into
mainfrom
feat/extra-mounts

Conversation

@woltspace-jerpint

@woltspace-jerpint woltspace-jerpint Bot commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

The problem

woltspace start hardcodes its bind mounts, so the container can only ever see the wolts directory. To let a wolt work on a repo living elsewhere on your machine, today you must either copy it under $WOLTS_DIR — where woltspace backup then snapshots it, and your code mixes into wolt state — or hand-roll docker run, which the next woltspace start wipes out (it does docker rm -f and rebuilds the args from scratch).

The change

A repeatable --mount <path> on woltspace start, taking a source path only:

woltspace start --mount ~/code/my-repo      # -> /mnt/my-repo

…plus WOLTSPACE_MOUNTS in $WOLTS_DIR/.env as a sticky, comma-separated equivalent, so a plain woltspace start keeps them:

WOLTSPACE_MOUNTS=~/code/my-repo,~/notes

The .env value is read with the same grep-the-env-file pattern WOLTSPACE_PUBLIC_TUNNEL already uses, so there's no new config mechanism.

Targets are always /mnt/<name>. Because the user never chooses a target, collisions with /workspace/wolts, /workspace/woltspace and /home/node/.claude are unreachable by construction rather than guarded against — and ls /mnt gives a wolt a discoverable list of everything it can reach. /mnt also sits outside /workspace, so it stays clear of the entrypoint's chown -R.

The name comes from basename on the resolved path rather than ${p##*/}, which returns empty on a trailing slash and would mount at /mnt/.

Validation, all before docker run and all failing fast:

  • source must exist
  • / is refused
  • two different sources sharing a directory name are rejected by name — docker's own Duplicate mount point: /mnt/api never says which two clashed. The same directory listed twice (flag and .env) is a harmless no-op, deduped rather than rejected.

--mount on an existing container is now an error, not a silent no-op. Bind mounts are fixed at docker run, and start only reaches _start_container when no container exists — a running one short-circuits at "already gnawing", a stopped one hits docker restart, which carries the old spec. So start --mount on a live lodge previously gave no mount and no error. It now exits with:

woltspace stop && woltspace start --mount ...

…noting that's a container recreate (seconds), not woltspace rebuild (minutes, and pointless here). Auto-recreate is deliberately not implemented — deferred until real usage warrants it. The check is gated on the explicit flag only; WOLTSPACE_MOUNTS applies whenever the container is created, matching how every other .env var already behaves.

Purely additive: with no flag and no env var, mount_args is empty and the docker run line is byte-for-byte what it was.

Testing

Target shell is macOS /bin/bash 3.2 — no associative arrays, so name/source collision tracking uses parallel indexed arrays and a linear scan. The script uses set -e but not set -u; "${arr[@]}" and ${#arr[@]} on empty arrays were verified to expand to zero words with no stray empty arg. Don't add set -u without auditing those.

Verified with bash -n, and functionally by extracting the arg-parsing loop, _start_container(), and the start) branch and running them against a stubbed docker on PATH that records its argv (the real call is > /dev/null, so the stub writes args to a file):

case result
--mount flag -v <src>:/mnt/<name>:rw reaches docker run
WOLTSPACE_MOUNTS in .env same, and composes with the flag
neither set starts clean, docker run args unchanged
source missing --mount source not found: …
source is / --mount refuses to mount /
trailing slash ~/code/my-repo/ resolves to /mnt/my-repo, not /mnt/
two sources, same basename names both paths, exits 1
same source listed twice deduped to one mount
--mount + container exists exits 1 with the stop/start message
no --mount, container exists guard does not fire
WOLTSPACE_MOUNTS + container exists guard does not fire
WOLTSPACE_MOUNTS=a, b (spaces after commas) both mount; whitespace trimmed
.env saved with CRLF last entry still mounts
trailing comma / all-whitespace field skipped, no error
source path containing a real space mounts as /mnt/my notes

Not covered by test/test-cli.sh — that harness does full docker build/start cycles, and this needed argv-level assertions. Happy to add a case there if you'd rather it live in the suite.

Docs

  • .env.exampleWOLTSPACE_MOUNTS as a comma-separated list of source paths
  • HUMANS.md — flag table row + "Mounting your own directories": the /mnt/<name> convention, that mounts are read-write against the real checkout, and that changing them needs woltspace stop && woltspace start (recreate, not rebuild)
  • CLAUDE.md — flag, env var, and a correction to the "the only mount is…" line

No .version or CHANGELOG.md bump — per VERSIONING.md those happen when a release is cut, not per PR. This does add a new optional env var, so whichever release picks it up is arguably a MINOR by that doc's rules; it needs no migration, since unset means current behavior.

🤖 Generated with Claude Code

@vercel

vercel Bot commented Aug 30, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
woltspace Ready Ready Preview Aug 31, 2026 12:37am

`woltspace start` hardcoded its bind mounts, so the container could only ever
see the wolts directory. Working on a repo that lives elsewhere on the host
meant copying it under $WOLTS_DIR — where `woltspace backup` then snapshots it
— or hand-rolling `docker run`, which the next `woltspace start` wipes out.

Adds a repeatable `--mount src:dst` flag, plus `WOLTSPACE_MOUNTS` in
$WOLTS_DIR/.env as a comma-separated sticky equivalent (read the same way
WOLTSPACE_PUBLIC_TUNNEL already is), so a plain `woltspace start` keeps them.

Entries expand `~`, resolve to an absolute path, and are mounted rw. Two
guards run before `docker run`, both failing fast with a clear message:
the source directory must exist, and the target may not collide with
/workspace/wolts, /workspace/woltspace, or /home/node/.claude.

Purely additive — with no flag and no env var, the docker run line is
byte-for-byte what it was.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@woltspace-jerpint

Copy link
Copy Markdown
Contributor Author

Applied the review — all five points. Force-pushed as f967b22.

1. Source-only, anchored at /mnt. No colon splitting at all now. This removed both bugs you spotted in the previous revision, which I confirmed before deleting the code:

  • the [ "$_src" = "$_dst" ] "malformed" check ran after ~ expansion, so --mount ~/code:/Users/me/code was rejected with a message about a different problem entirely
  • _dst="${_m#*:}" took everything after the first colon, so --mount src:dst:ro emitted -v src:dst:ro:rw — exactly the cryptic docker error this was meant to prevent

Also confirmed the trailing-slash trap you flagged: ${p##*/} on ~/code/my-repo/ really does yield an empty name. Using basename on the resolved path, with / rejected explicitly.

The case-based platform-mount guard is deleted. Checked container/entrypoint.sh — the recursive chown is chown -R node:node /home/node /workspace/woltspace, so /mnt is indeed outside it.

2. Basename collision guard. Names both source paths. One addition beyond the ask: the same directory listed twice (once as a flag, once in .env) is deduped as a no-op rather than erroring, since that composition is easy to hit by accident and harmless.

3. Fail fast on an existing container. Placed after the $LOCAL_BUILD block, before the dispatch, gated on the flag only. Verified all three branches: fires with --mount, stays quiet without it, stays quiet for WOLTSPACE_MOUNTS. No auto-recreate.

4. Docs updated to the source-only form, including the read-write warning and the recreate-not-rebuild note.

5. Testing. Full matrix in the PR description — eleven cases. bash 3.2 constraint respected: collision tracking uses parallel indexed arrays with a linear scan, not associative arrays. Confirmed the empty-array expansions are safe under set -e without set -u, and left a note not to add set -u without auditing them. No-mount path is byte-for-byte unchanged.

On read-only: left the door open deliberately rather than building it. Parsing the entry whole — no colon splitting — is what keeps a trailing :ro mode available as a clean extension later. Noted that reasoning in a comment above the parse loop so nobody reintroduces src:dst splitting and closes it off.

Review feedback on the initial src:dst form. The target is now always
/mnt/<name>, derived from the source, so a user never picks one.

That deletes the platform-mount collision guard outright rather than
improving it — /workspace/wolts, /workspace/woltspace and /home/node/.claude
are unreachable by construction. /mnt is also outside /workspace, clear of the
entrypoint's chown -R, and `ls /mnt` now shows a wolt everything it can reach.

Dropping colon splitting fixes two bugs in the previous form:

  - the "malformed" check compared _src to _dst *after* ~ expansion, so a
    legitimate identity mount (~/code:/Users/me/code) was rejected with a
    message about an entirely different problem
  - _dst="${_m#*:}" took everything after the first colon, so src:dst:ro
    emitted -v src:dst:ro:rw — the cryptic docker error this set out to prevent

It also keeps a trailing :ro mode available as a clean extension later, which
parsing the entry whole is what preserves.

The name uses basename on the resolved path, not ${p##*/}, which returns empty
on a trailing slash and would mount at /mnt/. / is refused explicitly.

Adds a basename collision guard: two different sources wanting the same
/mnt/<name> are rejected naming both paths, since docker's own "Duplicate
mount point" doesn't say which two clashed. The same source listed twice
(flag and .env) dedupes instead of failing.

Adds a fail-fast in the start) branch: bind mounts are fixed at docker run and
start only creates a container when none exists — a running one short-circuits,
a stopped one is docker restart'ed with its original spec — so start --mount on
an existing lodge silently did nothing. It now exits pointing at
`woltspace stop && woltspace start`, a recreate rather than a rebuild. Gated on
the flag alone; WOLTSPACE_MOUNTS applies whenever the container is created,
matching every other .env var. No auto-recreate, deferred until warranted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
IFS=',' splits on commas alone, so `WOLTSPACE_MOUNTS=~/code/a, ~/notes` kept
the leading space on the second entry and failed with

    --mount source not found:  /Users/me/notes

where the only clue is a leading space in the path — easy to miss. Spacing out
a comma-separated list is a natural thing to write, so trim both ends of each
field after the split.

[:space:] also covers a stray \r, so a .env saved with CRLF line endings no
longer breaks the last entry. A field that is entirely whitespace — from a
trailing comma — trims to empty and is skipped by the existing guard.

Only surrounding whitespace goes: a directory whose name genuinely contains a
space still mounts (verified `~/my notes` -> /mnt/my notes).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@woltspace-jerpint

Copy link
Copy Markdown
Contributor Author

Good catch — fixed in 9695731.

Trimmed both ends of each field after the IFS=',' split, using the nested-expansion form so it stays bash 3.2 safe:

_m="${_m#"${_m%%[![:space:]]*}"}"
_m="${_m%"${_m##*[![:space:]]}"}"

Two things that fell out of using [:space:] rather than a literal space:

  • a .env saved with CRLF line endings no longer breaks the last entry — the stray \r gets trimmed with everything else
  • a field that is entirely whitespace (from a trailing comma, ~/notes, ,) trims to empty and is caught by the existing [ -z "$_m" ] && continue, so it's skipped rather than erroring

Checked the obvious way this could go wrong: only surrounding whitespace is removed, so a directory whose name genuinely contains a space still works — ~/my notes mounts at /mnt/my notes.

Left flag entries untouched. The shell already handles quoting there, and --mount " ~/notes" would be deliberate.

Also documented the tolerance, since silently-accepted-but-undocumented is its own trap: .env.example notes spaces around commas are fine, and the HUMANS.md example now spaces its list out to show it.

Full matrix re-run after the change — the original eleven plus four new ones, all green. Thanks for running it on real 3.2; that's the environment I can't reach from in here.

The mount logic had grown to ~70 lines inline in _start_container(), which is
otherwise a short function about running one docker command. The file's own
convention is top-level _helper() functions, so follow it: _build_mount_args()
fills $mount_args, and _start_container() calls it.

Three things collapse in the move:

  - `cd`+`pwd` validates and resolves in one step, replacing a separate -d test
    followed by a separate resolve. Same trailing-slash normalization.
  - the name/source collision scan used two parallel arrays and an indexed while
    loop; one `seen` array of name::src entries and a `case` match does it in
    a third of the lines, and folds the same-dir-twice dedupe into the same test.
  - three multi-line error sites become _mount_fail calls, which also sends them
    to stderr where they belong rather than stdout.

Behaviour is unchanged — the full matrix still passes, including the no-mount
path leaving docker run byte-for-byte identical.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@woltspace-jerpint

Copy link
Copy Markdown
Contributor Author

Restructured in f011d89. You were right that it had gotten heavy — but the honest accounting is that most of the win is structural, not raw line count.

The real problem: ~70 lines of mount logic sat inline in _start_container(), a function otherwise about running one docker command. The file's own convention is top-level _helper() functions (_build_image, _show_url, _count_wolts…), and this didn't follow it. Now _build_mount_args() owns it and _start_container() calls it.

_start_container():  108 lines -> 40   (82 code lines -> 34)
mount logic itself:   49 code lines -> 40

Three things genuinely collapsed:

  • cd+pwd validates and resolves in one step. The separate -d test then separate resolve became if ! src=$(cd "$raw" 2>/dev/null && pwd). Same trailing-slash normalization, one check instead of two.
  • The collision scan lost its parallel arrays. Two arrays plus an indexed while loop became one seen array of name::src entries and a case match — and the same-dir-twice dedupe folds into the same test rather than needing its own flag.
  • Three multi-line error sites became _mount_fail calls, which also puts them on stderr, where they belong. They were going to stdout before; that was a small bug in its own right.

Behaviour is unchanged. Full matrix re-run — 17 cases including all the whitespace ones and all three container-guard branches — plus a check that the no-mount path still leaves docker run byte-for-byte identical.

If you want it smaller still, the remaining weight is the two friendly failures, and that's a real trade: the basename-collision guard (~10 lines) and the flag-plus-.env dedupe. Drop them and docker still refuses the container on a duplicate mount point — just with Duplicate mount point: /mnt/api and no indication which two sources collided. I'd keep them, since that error is exactly the kind this PR set out to prevent, but it's your call and I'm happy to cut them.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants