Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -347,10 +347,16 @@ Capture your entire dev environment and restore it on a new machine. An AI agent
```bash
crab env snapshot # Agent explores machine → encrypted bundle
crab env snapshot --dry-run # Preview what would be captured
crab env encrypt STAGING_DIR # Retry encryption without rerunning the agent
crab env restore --from FILE # Decrypt + agent sets up new machine
crab env restore --from URL # Download and restore from URL
```

If snapshot encryption fails, retry with the staging directory printed by the
command. An optional second argument selects the output file. The output must
be a new file outside the staging directory. Successful encryption removes the
staging directory; a failed attempt preserves it for retry.

The snapshot captures: Homebrew packages, Node/Python/Go versions, shell config, git identity, editor settings, database schemas, Docker config, cloud tools, project inventory, .env files, and API tokens — all encrypted with a password.

## Setup Flow
Expand Down
54 changes: 50 additions & 4 deletions src/crabcode
Original file line number Diff line number Diff line change
Expand Up @@ -11306,11 +11306,15 @@ handle_env_command() {
"restore")
env_restore "$@"
;;
"encrypt")
env_encrypt "$@"
;;
""|"help"|"-h"|"--help")
echo -e "${BOLD}crab env${NC} - Environment snapshot & restore"
echo ""
echo -e " ${CYAN}crab env snapshot${NC} Capture environment recipe"
echo -e " ${CYAN}crab env snapshot --dry-run${NC} Preview what would be captured"
echo -e " ${CYAN}crab env encrypt <staging-dir> [output-file]${NC} Retry snapshot encryption"
echo -e " ${CYAN}crab env restore --from FILE${NC} Restore from snapshot"
echo ""
echo "Snapshot launches an AI agent to explore your machine and build"
Expand Down Expand Up @@ -11492,7 +11496,30 @@ PROMPT_EOF
${EDITOR:-vim} "$staging_dir/recipe.md"
fi

# Package and encrypt
env_encrypt "$staging_dir" "$output_path"
}

# Encrypt a staging directory into a portable .enc bundle
env_encrypt() {
local staging_dir="${1:-}"
local output_path="${2:-}"

if [ -z "$staging_dir" ] || [ ! -d "$staging_dir" ]; then
error "Staging directory not found: ${staging_dir:-<none>}"
return 1
fi

if [ ! -f "$staging_dir/recipe.md" ]; then
error "No recipe.md found in $staging_dir — is this a valid snapshot?"
return 1
fi

staging_dir=$(cd "$staging_dir" && pwd -P) || return 1
if [ "$staging_dir" = / ]; then
error "The filesystem root cannot be a staging directory"
return 1
fi

echo ""
echo -e "${CYAN}Encrypting snapshot...${NC}"
echo -e "${GRAY}Choose a password to protect this snapshot.${NC}"
Expand All @@ -11501,13 +11528,31 @@ PROMPT_EOF

# Determine output path
if [ -z "$output_path" ]; then
local timestamp=$(date +%Y%m%d-%H%M%S)
output_path="$ENV_SNAPSHOT_DIR/env-snapshot-$timestamp.enc"
fi
mkdir -p "$(dirname "$output_path")"
mkdir -p "$(dirname "$output_path")" || return 1
local output_dir
output_dir=$(cd "$(dirname "$output_path")" && pwd -P) || return 1
output_path="$output_dir/$(basename "$output_path")"
case "$output_path" in
"$staging_dir"/*)
error "Output file must be outside the staging directory"
return 1
;;
esac
if [ -e "$output_path" ] || [ -L "$output_path" ]; then
error "Output file already exists: $output_path"
return 1
fi

local encrypted_tmp
encrypted_tmp=$(mktemp "$output_dir/.crab-env-XXXXXX") || return 1

# Encrypt with openssl (use pipefail to catch tar or openssl failures)
if ( set -o pipefail; tar czf - -C "$(dirname "$staging_dir")" "$(basename "$staging_dir")" \
| openssl enc -aes-256-cbc -pbkdf2 -salt -out "$output_path" ); then
if ( set -o pipefail; tar czf - -C "$(dirname "$staging_dir")" -- "$(basename "$staging_dir")" \
| openssl enc -aes-256-cbc -pbkdf2 -salt -out "$encrypted_tmp" ) \
&& mv "$encrypted_tmp" "$output_path"; then
# Clean up staging
rm -rf "$staging_dir"

Expand All @@ -11520,6 +11565,7 @@ PROMPT_EOF
echo -e " 2. Install crabcode: ${CYAN}curl -fsSL <install-url> | bash${NC}"
echo -e " 3. Run: ${CYAN}crab env restore --from $output_path${NC}"
else
rm -f "$encrypted_tmp"
error "Encryption failed"
echo "Unencrypted snapshot remains at: $staging_dir"
return 1
Expand Down
101 changes: 101 additions & 0 deletions tests/unit/test_env_encrypt.bats
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
#!/usr/bin/env bats

load '../test_helper/bats-support/load'
load '../test_helper/bats-assert/load'

setup() {
TEST_TMPDIR=$(mktemp -d)
source "${BATS_TEST_DIRNAME}/../../src/crabcode"
ENV_SNAPSHOT_DIR="$TEST_TMPDIR/snapshots"
staging_dir="$TEST_TMPDIR/staging"
mkdir -p "$staging_dir"
printf 'Fixture recipe\n' > "$staging_dir/recipe.md"
output_file="$TEST_TMPDIR/snapshot.enc"

# Use a fixture password so tests exercise real encryption without a prompt.
openssl() { command openssl "$@" -pass pass:crabcode-test-fixture; }
}

teardown() {
rm -rf "$TEST_TMPDIR"
}

@test "env encrypt rejects missing staging directories and recipes" {
run env_encrypt
assert_failure
assert_output --partial 'Staging directory not found'
rm "$staging_dir/recipe.md"
run env_encrypt "$staging_dir" "$output_file"
assert_failure
assert_output --partial 'No recipe.md'
[ ! -e "$output_file" ]
}

@test "env encrypt round-trips a relative staging path and removes it after success" {
cd "$TEST_TMPDIR"
run handle_env_command encrypt ./staging ./snapshot.enc
assert_success
[ ! -e "$staging_dir" ]
mkdir "$TEST_TMPDIR/restored"
openssl enc -d -aes-256-cbc -pbkdf2 -in "$output_file" \
| tar xzf - -C "$TEST_TMPDIR/restored"
run cat "$TEST_TMPDIR/restored/staging/recipe.md"
assert_output 'Fixture recipe'
}

@test "env encrypt supports staging names that begin with a dash" {
mv "$staging_dir" "$TEST_TMPDIR/--fixture"
run env_encrypt "$TEST_TMPDIR/--fixture" "$output_file"
assert_success
run bash -c 'openssl enc -d -aes-256-cbc -pbkdf2 -in "$1" -pass pass:crabcode-test-fixture | tar tzf -' _ "$output_file"
assert_success
assert_output --partial '--fixture/recipe.md'
}

@test "env encrypt preserves an existing output file" {
printf 'Previous backup\n' > "$output_file"
run env_encrypt "$staging_dir" "$output_file"
assert_failure
assert_output --partial 'already exists'
[ -f "$staging_dir/recipe.md" ]
run cat "$output_file"
assert_output 'Previous backup'
}

@test "env encrypt rejects output inside staging including symlinked directories" {
ln -s "$staging_dir" "$TEST_TMPDIR/link"
for destination in "$staging_dir/snapshot.enc" "$TEST_TMPDIR/link/snapshot.enc"; do
run env_encrypt "$staging_dir" "$destination"
assert_failure
assert_output --partial 'outside the staging directory'
[ -f "$staging_dir/recipe.md" ]
[ ! -e "$destination" ]
done
}

@test "env encrypt preserves staging and removes partial output on encryption failure" {
openssl() { cat >/dev/null; return 1; }
run env_encrypt "$staging_dir" "$output_file"
assert_failure
[ -f "$staging_dir/recipe.md" ]
[ ! -e "$output_file" ]
run find "$TEST_TMPDIR" -name '.crab-env-*'
assert_output ''
}

@test "env encrypt preserves staging when tar fails" {
tar() { return 1; }
run env_encrypt "$staging_dir" "$output_file"
assert_failure
[ -f "$staging_dir/recipe.md" ]
[ ! -e "$output_file" ]
}

@test "env encrypt uses the default snapshot directory when output is omitted" {
run handle_env_command encrypt "$staging_dir"
assert_success
[ ! -e "$staging_dir" ]
run find "$ENV_SNAPSHOT_DIR" -name 'env-snapshot-*.enc'
assert_success
[ -n "$output" ]
}
Loading