Skip to content

Commit bb9e3bb

Browse files
committed
Update path conventions in documentation and scripts for home-persist feature
1 parent 1743444 commit bb9e3bb

6 files changed

Lines changed: 40 additions & 13 deletions

File tree

docs/migration-guide.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -180,13 +180,14 @@ declare, list it on `home-persist`:
180180

181181
```jsonc
182182
"ghcr.io/sourecode/devcontainer-features/home-persist:1": {
183-
"paths": ".gitconfig,.bash_history,.config/my-tool"
183+
"paths": ".gitconfig,.bash_history,.config/my-tool/"
184184
}
185185
```
186186

187-
Paths are relative to `$HOME`. On first create, any existing content at
188-
those paths in the image gets moved into the volume; subsequent creates
189-
volume-win.
187+
Paths are relative to `$HOME`. Add a trailing `/` for directories (e.g.
188+
`.config/my-tool/`), omit it for files (e.g. `.gitconfig`). On first create,
189+
any existing content at those paths in the image gets moved into the volume;
190+
subsequent creates volume-win.
190191

191192
## Where env vars come from
192193

docs/persistence.md

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,16 +37,22 @@ cross-tool leakage between workspaces.
3737
```json
3838
{
3939
"source": "claude-code",
40-
"paths": [".claude", ".claude.json"]
40+
"paths": [".claude/", ".claude.json"]
4141
}
4242
```
4343

44+
**Trailing-slash convention**: a path ending in `/` is a directory; the
45+
resolver pre-creates the target so the symlink is never dangling. Use a
46+
slash for `.claude/` but not for `.claude.json` (a file). Without this,
47+
the first create on an empty volume leaves `~/.claude` as a dangling
48+
symlink, and any consumer doing `mkdir -p ~/.claude` fails with EEXIST.
49+
4450
Users can also declare project-local paths via the `paths` option on the
4551
`home-persist` feature:
4652

4753
```jsonc
4854
"ghcr.io/sourecode/devcontainer-features/home-persist:1": {
49-
"paths": ".claude,.claude.json,.gitconfig"
55+
"paths": ".claude/,.claude.json,.gitconfig"
5056
}
5157
```
5258

@@ -105,7 +111,7 @@ Properties that fall out:
105111

106112
| Source | Paths | Why |
107113
| -------------- | ----------------------------- | -------------------------------------------- |
108-
| `claude-code` | `.claude`, `.claude.json` | Login credentials, sessions, plugins |
114+
| `claude-code` | `.claude/`, `.claude.json` | Login credentials, sessions, plugins |
109115
| `user` (opt-in)| whatever you list | Project-local additions |
110116

111117
Anything not in the declared set is image-owned and resets on rebuild — git
@@ -132,11 +138,13 @@ mkdir -p /etc/devcontainer-persist.d
132138
cat > /etc/devcontainer-persist.d/my-feature.json <<'EOF'
133139
{
134140
"source": "my-feature",
135-
"paths": [".my-feature", ".config/my-feature"]
141+
"paths": [".my-feature/", ".config/my-feature/", ".my-feature.conf"]
136142
}
137143
EOF
138144
```
139145

146+
Trailing `/` for directories, no slash for files.
147+
140148
No ordering required — `home-persist`'s `onCreateCommand` runs after all
141149
features install, so the manifest is always visible by resolve time. Listing
142150
the same path in two manifests is harmless: the second is logged and

src/claude-code/devcontainer-feature.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"id": "claude-code",
3-
"version": "2.1.0",
3+
"version": "2.1.1",
44
"name": "claude-code",
55
"description": "Installs the latest Claude Code CLI via the official native installer, placing the binary in /usr/local/bin. Declares ~/.claude and ~/.claude.json as persistence targets via the home-persist manifest, so credentials, sessions, and plugins survive rebuilds when home-persist is installed.",
66
"documentationURL": "https://github.com/SoureCode/devcontainer-features/tree/master/src/claude-code",

src/claude-code/install.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,6 @@ mkdir -p /etc/devcontainer-persist.d
3131
cat > /etc/devcontainer-persist.d/claude-code.json <<'EOF'
3232
{
3333
"source": "claude-code",
34-
"paths": [".claude", ".claude.json"]
34+
"paths": [".claude/", ".claude.json"]
3535
}
3636
EOF

src/home-persist/devcontainer-feature.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
{
22
"id": "home-persist",
3-
"version": "1.0.0",
3+
"version": "1.1.0",
44
"name": "home-persist",
55
"description": "Symlinks selected paths under $HOME into a per-owner persistence volume mounted at /mnt/home-persist. Features and users declare paths via JSON manifests in /etc/devcontainer-persist.d/, and an onCreateCommand materializes the symlinks on every create. Requires the consumer to bind-mount a persistent source to /mnt/home-persist in devcontainer.json.",
66
"documentationURL": "https://github.com/SoureCode/devcontainer-features/tree/master/src/home-persist",
77
"options": {
88
"paths": {
99
"type": "string",
1010
"default": "",
11-
"description": "Comma-separated list of paths under $HOME to persist (e.g. '.claude,.claude.json,.gitconfig'). Written to /etc/devcontainer-persist.d/user.json at build time. Leave empty if you only want features to contribute paths."
11+
"description": "Comma-separated list of paths under $HOME to persist (e.g. '.claude/,.claude.json,.gitconfig'). A trailing slash marks a directory so the target is pre-created (prevents dangling-symlink errors in consumer scripts like `mkdir -p ~/.claude`). Written to /etc/devcontainer-persist.d/user.json at build time. Leave empty if you only want features to contribute paths."
1212
}
1313
},
1414
"onCreateCommand": "/usr/local/bin/home-persist-resolve"

src/home-persist/resolve.sh

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,13 @@
88
#
99
# Manifest shape:
1010
# { "source": "<label>", "paths": ["<rel-to-$HOME>", ...] }
11+
#
12+
# Path convention:
13+
# - Trailing slash ("/") means the path is a directory. The target is
14+
# pre-created so the symlink is never dangling — avoids `mkdir -p` on
15+
# a dangling symlink failing with EEXIST in consumer scripts.
16+
# - No trailing slash means the path is a file (or left dangling until
17+
# a writer creates it).
1118
set -euo pipefail
1219

1320
STATE="${HOME_PERSIST_STATE:-/mnt/home-persist}"
@@ -48,8 +55,12 @@ for mf in "${manifests[@]}"; do
4855
[ -z "$raw" ] && continue
4956
rel="${raw#\~/}"
5057
rel="${rel#/}"
58+
is_dir=0
59+
case "$rel" in
60+
*/) is_dir=1; rel="${rel%/}" ;;
61+
esac
5162
case "$rel" in
52-
*..*) log "rejecting path with .. in $mf: $raw"; continue ;;
63+
*..*|"") log "rejecting invalid path in $mf: $raw"; continue ;;
5364
esac
5465

5566
if [ -n "${owner[$rel]+x}" ]; then
@@ -65,6 +76,13 @@ for mf in "${manifests[@]}"; do
6576
if [ -e "$link" ] && [ ! -L "$link" ] && [ ! -e "$target" ]; then
6677
mv "$link" "$target"
6778
fi
79+
80+
# Pre-create directory targets so the symlink isn't dangling — consumer
81+
# scripts running `mkdir -p ~/<path>` would otherwise hit EEXIST.
82+
if [ "$is_dir" = 1 ] && [ ! -e "$target" ]; then
83+
mkdir -p "$target"
84+
fi
85+
6886
ln -sfn "$target" "$link"
6987
done < <(jq -r '.paths[]?' "$mf")
7088
done

0 commit comments

Comments
 (0)