Skip to content

Commit ca15e98

Browse files
authored
Merge pull request #93 from plone/copier-templates
Copier templates
2 parents a601814 + 5eea01e commit ca15e98

55 files changed

Lines changed: 4816 additions & 2114 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.claude/statusline-command.sh

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
#!/usr/bin/env bash
2+
input=$(cat)
3+
4+
model=$(echo "$input" | jq -r '.model.display_name // "unknown model"')
5+
output_style=$(echo "$input" | jq -r '.output_style.name // empty')
6+
used_pct=$(echo "$input" | jq -r '.context_window.used_percentage // empty')
7+
remaining_pct=$(echo "$input" | jq -r '.context_window.remaining_percentage // empty')
8+
9+
# Build status parts
10+
parts=()
11+
12+
# Model
13+
parts+=("$model")
14+
15+
# Context window
16+
if [ -n "$used_pct" ] && [ -n "$remaining_pct" ]; then
17+
ctx=$(printf "ctx: %.0f%% used / %.0f%% left" "$used_pct" "$remaining_pct")
18+
parts+=("$ctx")
19+
fi
20+
21+
# Output style / thinking
22+
if [ -n "$output_style" ] && [ "$output_style" != "default" ]; then
23+
parts+=("style: $output_style")
24+
fi
25+
26+
# Join with separator
27+
result=""
28+
for part in "${parts[@]}"; do
29+
if [ -z "$result" ]; then
30+
result="$part"
31+
else
32+
result="$result | $part"
33+
fi
34+
done
35+
36+
echo "$result"

.devcontainer/Dockerfile

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
FROM node:20
2+
3+
ARG TZ
4+
ENV TZ="$TZ"
5+
6+
# Install basic development tools, iptables/ipset, and chromium
7+
RUN apt-get update && apt-get install -y --no-install-recommends \
8+
less \
9+
git \
10+
procps \
11+
sudo \
12+
fzf \
13+
zsh \
14+
man-db \
15+
unzip \
16+
gnupg2 \
17+
gh \
18+
iptables \
19+
ipset \
20+
iproute2 \
21+
dnsutils \
22+
aggregate \
23+
jq \
24+
nano \
25+
vim \
26+
chromium \
27+
python3 \
28+
python3-venv \
29+
&& apt-get clean && rm -rf /var/lib/apt/lists/*
30+
31+
# Ensure default node user has access to /usr/local/share
32+
RUN mkdir -p /usr/local/share/npm-global && \
33+
chown -R node:node /usr/local/share
34+
35+
ARG USERNAME=node
36+
37+
# Persist bash history.
38+
RUN SNIPPET="export PROMPT_COMMAND='history -a' && export HISTFILE=/commandhistory/.bash_history" \
39+
&& mkdir /commandhistory \
40+
&& touch /commandhistory/.bash_history \
41+
&& chown -R $USERNAME /commandhistory
42+
43+
# Set `DEVCONTAINER` environment variable to help with orientation
44+
ENV DEVCONTAINER=true
45+
46+
# Create workspace and config directories and set permissions
47+
RUN mkdir -p /workspace /home/node/.claude && \
48+
chown -R node:node /workspace /home/node/.claude
49+
50+
WORKDIR /workspace
51+
52+
ARG GIT_DELTA_VERSION=0.18.2
53+
RUN ARCH=$(dpkg --print-architecture) && \
54+
wget "https://github.com/dandavison/delta/releases/download/${GIT_DELTA_VERSION}/git-delta_${GIT_DELTA_VERSION}_${ARCH}.deb" && \
55+
sudo dpkg -i "git-delta_${GIT_DELTA_VERSION}_${ARCH}.deb" && \
56+
rm "git-delta_${GIT_DELTA_VERSION}_${ARCH}.deb"
57+
58+
# Set up non-root user
59+
USER node
60+
61+
# Install global packages
62+
ENV NPM_CONFIG_PREFIX=/usr/local/share/npm-global
63+
ENV PATH=$HOME/.local/bin:$PATH:/usr/local/share/npm-global/bin
64+
65+
# Set the default shell to zsh rather than sh
66+
ENV SHELL=/bin/zsh
67+
68+
# Set the default editor and visual
69+
ENV EDITOR=nano
70+
ENV VISUAL=nano
71+
72+
# Default powerline10k theme
73+
ARG ZSH_IN_DOCKER_VERSION=1.2.0
74+
RUN sh -c "$(wget -O- https://github.com/deluan/zsh-in-docker/releases/download/v${ZSH_IN_DOCKER_VERSION}/zsh-in-docker.sh)" -- \
75+
-p git \
76+
-p fzf \
77+
-a "source /usr/share/doc/fzf/examples/key-bindings.zsh" \
78+
-a "source /usr/share/doc/fzf/examples/completion.zsh" \
79+
-a "export PROMPT_COMMAND='history -a' && export HISTFILE=/commandhistory/.bash_history" \
80+
-x
81+
82+
# Copy and set up firewall and Claude setup scripts
83+
COPY init-firewall.sh /usr/local/bin/
84+
COPY setup-claude.sh /usr/local/bin/
85+
USER root
86+
RUN chmod +x /usr/local/bin/init-firewall.sh /usr/local/bin/setup-claude.sh && \
87+
echo "node ALL=(root) NOPASSWD: /usr/local/bin/init-firewall.sh" > /etc/sudoers.d/node-firewall && \
88+
chmod 0440 /etc/sudoers.d/node-firewall
89+
90+
# Chromium flags for running in container
91+
ENV CHROME_PATH=/usr/bin/chromium
92+
ENV CHROMIUM_FLAGS="--no-sandbox --disable-gpu --disable-dev-shm-usage"
93+
94+
USER node
95+
96+
# Install UV package manager
97+
RUN curl -LsSf https://astral.sh/uv/install.sh | sh
98+
99+
# Install Claude Code (native) and OpenCode into ~/.local/bin (persisted in image)
100+
RUN mkdir -p "$HOME/.local/bin" && \
101+
curl -fsSL https://claude.ai/install.sh | bash && \
102+
curl -fsSL https://opencode.ai/install | bash

.devcontainer/devcontainer.json

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
{
2+
"name": "Claude Code Sandbox",
3+
"build": {
4+
"dockerfile": "Dockerfile",
5+
"args": {
6+
"TZ": "${localEnv:TZ:America/Los_Angeles}",
7+
"GIT_DELTA_VERSION": "0.18.2",
8+
"ZSH_IN_DOCKER_VERSION": "1.2.0"
9+
}
10+
},
11+
12+
"runArgs": ["--cap-add=NET_ADMIN", "--cap-add=NET_RAW"],
13+
"customizations": {
14+
"vscode": {
15+
"extensions": [
16+
"anthropic.claude-code",
17+
"dbaeumer.vscode-eslint",
18+
"esbenp.prettier-vscode",
19+
"eamodio.gitlens",
20+
"ms-python.python"
21+
],
22+
"settings": {
23+
"editor.formatOnSave": true,
24+
"editor.defaultFormatter": "esbenp.prettier-vscode",
25+
"editor.codeActionsOnSave": {
26+
"source.fixAll.eslint": "explicit"
27+
},
28+
"terminal.integrated.defaultProfile.linux": "zsh",
29+
"terminal.integrated.profiles.linux": {
30+
"bash": {
31+
"path": "bash",
32+
"icon": "terminal-bash"
33+
},
34+
"zsh": {
35+
"path": "zsh"
36+
}
37+
}
38+
}
39+
}
40+
},
41+
"remoteUser": "node",
42+
"mounts": [
43+
"source=claude-code-bashhistory-${devcontainerId},target=/commandhistory,type=volume",
44+
"source=claude-code-config-${devcontainerId},target=/home/node/.claude,type=volume",
45+
"source=${localEnv:HOME}/develop/plone/src/copier-templates,target=/home/node/develop/plone/src/copier-templates,type=bind,consistency=delegated",
46+
"source=${localEnv:HOME}/.copier-templates/plone-copier-templates,target=/home/node/.copier-templates/plone-copier-templates,type=bind,readonly"
47+
],
48+
"containerEnv": {
49+
"CLAUDE_CONFIG_DIR": "/home/node/.claude",
50+
"POWERLEVEL9K_DISABLE_GITSTATUS": "true",
51+
"CHROME_PATH": "/usr/bin/chromium",
52+
"PUPPETEER_EXECUTABLE_PATH": "/usr/bin/chromium"
53+
},
54+
"workspaceMount": "source=${localWorkspaceFolder},target=/workspace,type=bind,consistency=delegated",
55+
"workspaceFolder": "/workspace",
56+
"postCreateCommand": "/usr/local/bin/setup-claude.sh",
57+
"postStartCommand": "sudo /usr/local/bin/init-firewall.sh",
58+
"waitFor": "postStartCommand"
59+
}

.devcontainer/init-firewall.sh

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
#!/bin/bash
2+
set -euo pipefail # Exit on error, undefined vars, and pipeline failures
3+
IFS=$'\n\t' # Stricter word splitting
4+
# Extract Docker DNS info BEFORE any flushing
5+
DOCKER_DNS_RULES=$(iptables-save -t nat | grep "127\.0\.0\.11" || true)
6+
7+
# Flush existing rules and delete existing ipsets
8+
iptables -F
9+
iptables -X
10+
iptables -t nat -F
11+
iptables -t nat -X
12+
iptables -t mangle -F
13+
iptables -t mangle -X
14+
ipset destroy allowed-domains 2>/dev/null || true
15+
16+
# Selectively restore ONLY internal Docker DNS resolution
17+
if [ -n "$DOCKER_DNS_RULES" ]; then
18+
echo "Restoring Docker DNS rules..."
19+
iptables -t nat -N DOCKER_OUTPUT 2>/dev/null || true
20+
iptables -t nat -N DOCKER_POSTROUTING 2>/dev/null || true
21+
echo "$DOCKER_DNS_RULES" | xargs -L 1 iptables -t nat
22+
else
23+
echo "No Docker DNS rules to restore"
24+
fi
25+
26+
# First allow DNS and localhost before any restrictions
27+
# Allow outbound DNS
28+
iptables -A OUTPUT -p udp --dport 53 -j ACCEPT
29+
# Allow inbound DNS responses
30+
iptables -A INPUT -p udp --sport 53 -j ACCEPT
31+
# Allow outbound SSH
32+
iptables -A OUTPUT -p tcp --dport 22 -j ACCEPT
33+
# Allow inbound SSH responses
34+
iptables -A INPUT -p tcp --sport 22 -m state --state ESTABLISHED -j ACCEPT
35+
# Allow localhost
36+
iptables -A INPUT -i lo -j ACCEPT
37+
iptables -A OUTPUT -o lo -j ACCEPT
38+
39+
# Create ipset with CIDR support
40+
ipset create allowed-domains hash:net
41+
42+
# Fetch GitHub meta information and aggregate + add their IP ranges
43+
echo "Fetching GitHub IP ranges..."
44+
gh_ranges=$(curl -s https://api.github.com/meta)
45+
if [ -z "$gh_ranges" ]; then
46+
echo "ERROR: Failed to fetch GitHub IP ranges"
47+
exit 1
48+
fi
49+
50+
if ! echo "$gh_ranges" | jq -e '.web and .api and .git' >/dev/null; then
51+
echo "ERROR: GitHub API response missing required fields"
52+
exit 1
53+
fi
54+
55+
echo "Processing GitHub IPs..."
56+
while read -r cidr; do
57+
if [[ ! "$cidr" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}/[0-9]{1,2}$ ]]; then
58+
echo "ERROR: Invalid CIDR range from GitHub meta: $cidr"
59+
exit 1
60+
fi
61+
echo "Adding GitHub range $cidr"
62+
ipset add allowed-domains "$cidr" 2>/dev/null || true
63+
done < <(echo "$gh_ranges" | jq -r '(.web + .api + .git)[]' | aggregate -q)
64+
65+
# Resolve and add other allowed domains
66+
for domain in \
67+
"registry.npmjs.org" \
68+
"api.anthropic.com" \
69+
"claude.ai" \
70+
"sentry.io" \
71+
"statsig.anthropic.com" \
72+
"statsig.com" \
73+
"marketplace.visualstudio.com" \
74+
"vscode.blob.core.windows.net" \
75+
"update.code.visualstudio.com" \
76+
"opencode.ai"; do
77+
echo "Resolving $domain..."
78+
ips=$(dig +noall +answer A "$domain" | awk '$4 == "A" {print $5}')
79+
if [ -z "$ips" ]; then
80+
echo "ERROR: Failed to resolve $domain"
81+
exit 1
82+
fi
83+
84+
while read -r ip; do
85+
if [[ ! "$ip" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then
86+
echo "ERROR: Invalid IP from DNS for $domain: $ip"
87+
exit 1
88+
fi
89+
echo "Adding $ip for $domain"
90+
ipset add allowed-domains "$ip" 2>/dev/null || true
91+
done < <(echo "$ips")
92+
done
93+
94+
# PyPI (for UV package installation)
95+
for domain in \
96+
"pypi.org" \
97+
"files.pythonhosted.org" \
98+
"dist.plone.org"; do
99+
echo "Resolving $domain..."
100+
ips=$(dig +noall +answer A "$domain" | awk '$4 == "A" {print $5}')
101+
if [ -z "$ips" ]; then
102+
echo "ERROR: Failed to resolve $domain"
103+
exit 1
104+
fi
105+
106+
while read -r ip; do
107+
if [[ ! "$ip" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then
108+
echo "ERROR: Invalid IP from DNS for $domain: $ip"
109+
exit 1
110+
fi
111+
echo "Adding $ip for $domain"
112+
ipset add allowed-domains "$ip" 2>/dev/null || true
113+
done < <(echo "$ips")
114+
done
115+
116+
# Get host IP from default route
117+
HOST_IP=$(ip route | grep default | cut -d" " -f3)
118+
if [ -z "$HOST_IP" ]; then
119+
echo "ERROR: Failed to detect host IP"
120+
exit 1
121+
fi
122+
123+
HOST_NETWORK=$(echo "$HOST_IP" | sed "s/\.[0-9]*$/.0\/24/")
124+
echo "Host network detected as: $HOST_NETWORK"
125+
126+
# Set up remaining iptables rules
127+
iptables -A INPUT -s "$HOST_NETWORK" -j ACCEPT
128+
iptables -A OUTPUT -d "$HOST_NETWORK" -j ACCEPT
129+
130+
# Set default policies to DROP first
131+
iptables -P INPUT DROP
132+
iptables -P FORWARD DROP
133+
iptables -P OUTPUT DROP
134+
135+
# First allow established connections for already approved traffic
136+
iptables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
137+
iptables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
138+
139+
# Then allow only specific outbound traffic to allowed domains
140+
iptables -A OUTPUT -m set --match-set allowed-domains dst -j ACCEPT
141+
142+
# Explicitly REJECT all other outbound traffic for immediate feedback
143+
iptables -A OUTPUT -j REJECT --reject-with icmp-admin-prohibited
144+
145+
echo "Firewall configuration complete"
146+
echo "Verifying firewall rules..."
147+
148+
# Verify blocked traffic using a direct IP (1.1.1.1) to avoid DNS issues
149+
# (container DNS may resolve blocked domains to 127.0.0.1, bypassing the firewall)
150+
if curl --connect-timeout 5 -s -o /dev/null https://1.1.1.1 2>/dev/null; then
151+
echo "ERROR: Firewall verification failed - was able to reach https://1.1.1.1"
152+
exit 1
153+
else
154+
echo "Firewall verification passed - unable to reach https://1.1.1.1 as expected"
155+
fi
156+
157+
# Verify GitHub API access
158+
if ! curl --connect-timeout 5 https://api.github.com/zen >/dev/null 2>&1; then
159+
echo "ERROR: Firewall verification failed - unable to reach https://api.github.com"
160+
exit 1
161+
else
162+
echo "Firewall verification passed - able to reach https://api.github.com as expected"
163+
fi

.devcontainer/setup-claude.sh

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
#!/bin/bash
2+
set -euo pipefail
3+
4+
# Claude Code and OpenCode are installed into the image via the Dockerfile.
5+
# This script only handles per-container configuration that depends on the
6+
# mounted ~/.claude volume.
7+
8+
export PATH="$HOME/.local/bin:$PATH"
9+
10+
if command -v claude &>/dev/null; then
11+
echo "Configuring Chrome DevTools MCP server..."
12+
claude mcp remove chrome-devtools 2>/dev/null || true
13+
claude mcp add chrome-devtools -- npx -y chrome-devtools-mcp@latest
14+
else
15+
echo "WARNING: claude CLI not found, skipping MCP configuration"
16+
fi
17+
18+
echo "Enabling remote control for all sessions..."
19+
mkdir -p ~/.claude
20+
SETTINGS_FILE="$HOME/.claude/settings.json"
21+
if [ -f "$SETTINGS_FILE" ]; then
22+
# Merge preferRemoteControl into existing settings using node (available in the container)
23+
node -e "
24+
const fs = require('fs');
25+
const s = JSON.parse(fs.readFileSync('$SETTINGS_FILE', 'utf8'));
26+
s.preferRemoteControl = true;
27+
fs.writeFileSync('$SETTINGS_FILE', JSON.stringify(s, null, 2));
28+
"
29+
else
30+
echo '{"preferRemoteControl": true}' > "$SETTINGS_FILE"
31+
fi
32+
33+
echo "Claude Code and OpenCode setup complete."

0 commit comments

Comments
 (0)