Skip to content
Draft
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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,14 @@ cd instances/my-advisor && docker compose down
- Manually specify a port with `./scripts/hatch.sh my-instance --port 18800`
- Port assignments are tracked in `fleet.json` to prevent conflicts

**Remote Deploy via SSH:**
```bash
# Deploy directly to a remote host
./scripts/hatch.sh my-instance --port 18800 --host ssh://user@hostname --path /opt/hatchery/instances/my-instance
```
This scaffolds the instance locally, rsyncs files to the remote host, and runs `docker compose up -d --build` there.
The `--path` flag is optional (defaults to `/opt/hatchery/instances/<name>`).

See [TESTING.md](TESTING.md) for troubleshooting.

## Testing
Expand Down
78 changes: 67 additions & 11 deletions scripts/hatch.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,51 @@
set -euo pipefail

# hatch.sh — Create a new Hatchery instance from template
# Usage: ./scripts/hatch.sh <name> [--port PORT]
# Usage: ./scripts/hatch.sh <name> [--port PORT] [--host ssh://user@host] [--path /remote/path]

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ROOT_DIR="$(dirname "$SCRIPT_DIR")"
TEMPLATE_DIR="$ROOT_DIR/template"
INSTANCES_DIR="$ROOT_DIR/instances"
FLEET_REGISTRY="$ROOT_DIR/fleet.json"

NAME="${1:?Usage: hatch.sh <name> [--port PORT]}"
NAME="${1:?Usage: hatch.sh <name> [--port PORT] [--host ssh://user@host] [--path /remote/path]}"
PORT=""
AUTO_PORT=false
SSH_HOST=""
SSH_USER=""
REMOTE_PATH=""

shift
while [[ $# -gt 0 ]]; do
case "$1" in
--port) PORT="$2"; shift 2 ;;
--host)
host_arg="$2"
if [[ "$host_arg" =~ ^ssh://([^@]+)@(.+)$ ]]; then
SSH_USER="${BASH_REMATCH[1]}"
SSH_HOST="${BASH_REMATCH[2]}"
elif [[ "$host_arg" =~ ^ssh://(.+)$ ]]; then
SSH_HOST="${BASH_REMATCH[1]}"
else
echo "Error: Invalid --host format. Expected ssh://[user@]host" >&2
exit 1
fi
shift 2
;;
--path) REMOTE_PATH="$2"; shift 2 ;;
*) echo "Unknown option: $1" >&2; exit 1 ;;
esac
done

# Validate --path when --host is provided
if [[ -n "$SSH_HOST" && -n "$REMOTE_PATH" ]]; then
if [[ ! "$REMOTE_PATH" =~ ^/[a-zA-Z0-9/_.-]+$ ]]; then
echo "Error: --path must be an absolute path with only alphanumeric, '/', '-', '_', '.' characters" >&2
exit 1
fi
fi

# Initialize fleet registry if it doesn't exist
if [[ ! -f "$FLEET_REGISTRY" ]]; then
echo '{"instances":{}}' > "$FLEET_REGISTRY"
Expand Down Expand Up @@ -122,7 +147,10 @@ EOF
# Update fleet registry
tmp_file=$(mktemp)
jq --arg name "$NAME" --arg port "$PORT" --arg created "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
'.instances[$name] = {port: ($port | tonumber), created: $created}' \
--arg ssh_host "$SSH_HOST" --arg ssh_user "$SSH_USER" \
'.instances[$name] = {port: ($port | tonumber), created: $created} |
if $ssh_host != "" then .instances[$name].ssh_host = $ssh_host else . end |
if $ssh_user != "" then .instances[$name].ssh_user = $ssh_user else . end' \
"$FLEET_REGISTRY" > "$tmp_file" && mv "$tmp_file" "$FLEET_REGISTRY"

echo ""
Expand All @@ -131,13 +159,41 @@ if [[ "$AUTO_PORT" == "true" ]]; then
else
echo "✅ Instance created at: $INSTANCE_DIR"
fi
echo ""
echo "Next steps:"
echo " 1. Edit workspace files (SOUL.md, IDENTITY.md, USER.md)"
echo " 2. Add reference docs to workspace/reference/"
echo " 3. Copy .env.example to .env and add API keys:"
echo " cp $INSTANCE_DIR/.env.example $INSTANCE_DIR/.env"
echo " 4. Launch:"
echo " cd $INSTANCE_DIR && docker compose up -d"

# Remote deploy via SSH
if [[ -n "$SSH_HOST" ]]; then
SSH_TARGET="${SSH_USER:+${SSH_USER}@}${SSH_HOST}"
DEST_PATH="${REMOTE_PATH:-/opt/hatchery/instances/$NAME}"

# Validate default DEST_PATH (only needed when REMOTE_PATH was not set)
if [[ -z "$REMOTE_PATH" && ! "$DEST_PATH" =~ ^/[a-zA-Z0-9/_.-]+$ ]]; then
echo "Error: computed remote path contains unsafe characters: $DEST_PATH" >&2
exit 1
fi

echo ""
echo "🚀 Deploying to ${SSH_TARGET}:${DEST_PATH}..."

ssh "$SSH_TARGET" "mkdir -p $(printf '%q' "$DEST_PATH")"
rsync -az "$INSTANCE_DIR/" "${SSH_TARGET}:${DEST_PATH}/"
ssh "$SSH_TARGET" "cd $(printf '%q' "$DEST_PATH") && docker compose up -d --build"

echo ""
echo "✅ Deployed to ${SSH_TARGET}:${DEST_PATH}"
echo ""
echo "Next steps:"
echo " 1. Edit workspace files on the remote host or push updates with rsync"
echo " 2. Copy .env.example to .env on the remote and add API keys:"
echo " ssh ${SSH_TARGET} \"cp ${DEST_PATH}/.env.example ${DEST_PATH}/.env\""
else
echo ""
echo "Next steps:"
echo " 1. Edit workspace files (SOUL.md, IDENTITY.md, USER.md)"
echo " 2. Add reference docs to workspace/reference/"
echo " 3. Copy .env.example to .env and add API keys:"
echo " cp $INSTANCE_DIR/.env.example $INSTANCE_DIR/.env"
echo " 4. Launch:"
echo " cd $INSTANCE_DIR && docker compose up -d"
fi
echo ""
echo "🦞 Happy hatching!"
36 changes: 36 additions & 0 deletions scripts/test-hatch.sh
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,42 @@ else
test_fail ".gitignore not found"
fi

# Test 13: Verify --host invalid format is rejected
echo "Test 13: Verifying --host rejects invalid format..."
if ! "$SCRIPT_DIR/hatch.sh" "test-invalid-host-$$" --port 18799 --host "notanurl" 2>/dev/null; then
test_pass "--host with invalid format correctly rejected"
else
test_fail "--host with invalid format should have been rejected"
rm -rf "$ROOT_DIR/instances/test-invalid-host-$$" 2>/dev/null || true
fi

# Test 14: Verify --host ssh://user@host stores ssh_host/ssh_user in fleet.json (local scaffold, no SSH)
echo "Test 14: Verifying --host ssh://user@host is parsed and stored in fleet.json..."
TEST_SSH_INSTANCE="test-ssh-$$"
TEST_SSH_DIR="$ROOT_DIR/instances/$TEST_SSH_INSTANCE"
# Stub ssh and rsync to succeed without connecting
SSH_STUB_DIR=$(mktemp -d)
printf '%s\n' '#!/bin/sh' 'exit 0' > "$SSH_STUB_DIR/ssh"
printf '%s\n' '#!/bin/sh' 'exit 0' > "$SSH_STUB_DIR/rsync"
chmod +x "$SSH_STUB_DIR/ssh" "$SSH_STUB_DIR/rsync"
if PATH="$SSH_STUB_DIR:$PATH" "$SCRIPT_DIR/hatch.sh" "$TEST_SSH_INSTANCE" --port 18798 --host ssh://testuser@testhost --path /tmp/test-path > /dev/null 2>&1; then
# Verify ssh_host and ssh_user in fleet.json
ssh_host_val=$(jq -r ".instances[\"$TEST_SSH_INSTANCE\"].ssh_host // empty" "$ROOT_DIR/fleet.json" 2>/dev/null || true)
ssh_user_val=$(jq -r ".instances[\"$TEST_SSH_INSTANCE\"].ssh_user // empty" "$ROOT_DIR/fleet.json" 2>/dev/null || true)
if [[ "$ssh_host_val" == "testhost" ]] && [[ "$ssh_user_val" == "testuser" ]]; then
test_pass "ssh_host and ssh_user stored in fleet.json"
else
test_fail "ssh_host/ssh_user not correctly stored (got host='$ssh_host_val' user='$ssh_user_val')"
fi
# cleanup
rm -rf "$TEST_SSH_DIR" "$SSH_STUB_DIR" 2>/dev/null || true
tmp_file=$(mktemp)
jq "del(.instances[\"$TEST_SSH_INSTANCE\"])" "$ROOT_DIR/fleet.json" > "$tmp_file" && mv "$tmp_file" "$ROOT_DIR/fleet.json"
else
test_fail "--host ssh://user@host invocation failed unexpectedly"
rm -rf "$TEST_SSH_DIR" "$SSH_STUB_DIR" 2>/dev/null || true
fi

# Summary
echo ""
echo "========================================"
Expand Down