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
26 changes: 26 additions & 0 deletions .ai/context/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,32 @@ The project uses semantic-release with GitHub Actions:
- `feat:` → minor version
- `fix:`, `docs:`, `style:`, etc. → patch version

## Console Output

Three indents and three symbols, used the same way everywhere, so a run can be
skimmed down the left edge. The rule lives next to `CloudXSetup.print_status`
in `cloudx_proxy/setup.py`; `tests/test_cli_options.py` enforces it.

```
=== Section === a phase of the command (print_header)
○ Doing something... STEP: one operation within the section
✓ It worked DETAIL: what that step found or did
○ ... SUB: detail of a nested operation
```

- `○` neutral - about to happen, in progress, informational, or a dry-run
preview
- `✓` true now - succeeded, exists, verified
- `✗` wrong - failed, missing, invalid

Indents are 0, 2 and 4; nothing else. A detail must have a step above it, and a
sub-detail a detail. A ✗ marks the *outcome*: a condition the code goes on to
recover from is `○`, so one failure shows as one ✗ rather than one per line on
the way there.

`print_header` emits a single blank line before the header, and callers printing
a banner above the first section add none of their own - the two used to stack.

## Publishing to PyPI

The package is automatically published to PyPI via GitHub Actions when a new release is created. Setup:
Expand Down
37 changes: 34 additions & 3 deletions .ai/context/ssh-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,14 @@ Host cloudX-Prod-*

**Settings explained**:
- `IdentityFile`: Path to the SSH private key for this environment
- `IdentitiesOnly yes`: Only use the specified key, don't try others from ssh-agent
- `IdentitiesOnly yes`: Only use the specified key, don't try others from ssh-agent.
With 1Password this is why `IdentityFile` names the *public* key: ssh offers only
identities named by `IdentityFile`, even when the agent holds more, so the `.pub`
is what lets the agent's copy of the key be used at all.
- `IdentityAgent`: Only written with `--1password`, and only where the agent has to be
named: `~/.1password/agent.sock` on macOS and Linux (or the snap path on snap
installs). Nothing is written on Windows, where 1Password serves the standard
OpenSSH named pipe `\\.\pipe\openssh-ssh-agent` that ssh already uses by default.
- `ProxyCommand`: The cloudX-proxy connect command that:
- Checks if the instance is running (starts it if needed)
- Pushes the SSH public key to the instance
Expand Down Expand Up @@ -101,13 +108,37 @@ Host cloudX-Prod-foobar
- The `Environment` tag determines the environment part
- The `Name` tag (or user-specified hostname) determines the hostname part

## Prefix Case

ssh matches `Host` **patterns** case-sensitively, and the pattern syntax supports
only `*` and `?` - `cloud[xX]-*` is a literal hostname, not a character class. A
block written as `Host cloudX-*` therefore does not apply to a host entry spelled
`cloudx-dev-web1`, and both spellings exist in the wild (two command names, older
releases, hand edits).

Wildcard blocks are consequently written with one pattern per prefix spelling -
`Host cloudX-* cloudx-*` - since a `Host` line takes any number of patterns and
matches if any one of them does. The configured spelling always comes first.

Only the prefix varies between the patterns. The environment part is set by
whoever rolled out the environment stack and the host part is chosen by the user;
both are written exactly as given.

Host entries are never rewritten. `cloudX` is the product's name - the X is ten,
after Cloud9 - but people who would rather not reach for shift call their instance
`cloudx-dev-something`, and that name is theirs: it is what they type, what `list`
reports and what VSCode offers. The widened wildcard blocks match it either way, so
it does not need renaming to inherit its settings. Re-running `setup` for an
existing host updates its `HostName` and keeps its name; only a brand new entry is
written in the configured case.

## Configuration Inheritance

SSH applies configurations from most specific to least specific. When connecting to `cloudX-Prod-foobar`:

1. **Host cloudX-Prod-foobar** matches first → sets `HostName`
2. **Host cloudX-Prod-*** matches next → sets `IdentityFile`, `IdentitiesOnly`, `ProxyCommand`
3. **Host cloudX-*** matches last → sets `User`, `TCPKeepAlive`, `Control*`
2. **Host cloudX-Prod-* cloudx-Prod-*** matches next → sets `IdentityFile`, `IdentitiesOnly`, `ProxyCommand`
3. **Host cloudX-* cloudx-*** matches last → sets `User`, `TCPKeepAlive`, `Control*`

The result is a fully configured connection with all necessary settings.

Expand Down
36 changes: 29 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,7 @@ Will create a three-tier configuration structure like this:
# Generic configuration (shared by all environments)
# Created by cloudX-proxy v1.0.0 on 2025-03-07 09:05:23
# Configuration type: generic
Host cloudX-*
Host cloudX-* cloudx-*
User ec2-user
TCPKeepAlive yes
ControlMaster auto
Expand All @@ -262,7 +262,7 @@ Host cloudX-*
# Environment configuration (specific to a single environment)
# Created by cloudX-proxy v1.0.0 on 2025-03-07 09:05:23
# Configuration type: environment
Host cloudX-dev-*
Host cloudX-dev-* cloudx-dev-*
IdentityFile ~/.ssh/cloudX/mykey
IdentitiesOnly yes
ProxyCommand uvx cloudX-proxy connect %h %p --profile myprofile --ssh-key mykey --ssh-dir ~/.ssh/cloudX
Expand Down Expand Up @@ -429,8 +429,23 @@ Understanding the connection flow helps with troubleshooting and explains why ce
### Command Line Options

> **Note:** Both `cloudX-proxy` (preferred) and `cloudx-proxy` command names are available. The command name determines the prefix case used in SSH configurations:
> - `cloudX-proxy` generates `Host cloudX-*` patterns (preferred)
> - `cloudx-proxy` generates `Host cloudx-*` patterns
> - `cloudX-proxy` generates `Host cloudX-* cloudx-*` patterns (preferred)
> - `cloudx-proxy` generates `Host cloudx-* cloudX-*` patterns
>
> ssh matches `Host` patterns case-sensitively, and its pattern syntax has only
> `*` and `?` - there is no `[xX]` character class. Wildcard blocks therefore list
> both spellings, so a config that mixes them keeps working; the first pattern is
> the one the command name chose.
>
> Only the prefix varies. The environment part is fixed by whoever rolled out the
> environment stack, and the host part is chosen by the user - both are written
> exactly as given. An instance called `cloudx-dev-web1` keeps that name and still
> picks up its settings from `Host cloudX-dev-* cloudx-dev-*`.
>
> `list` reflects that split: hosts appear under the names their owners gave them,
> while patterns are shown as `cloudX-*` whichever command name you type, since
> `cloudX` is the preferred spelling. An environment with no hosts of its own is
> listed only as a pattern (under `--detailed`), not as an environment.
>
> Run `cleanup` with your preferred command to normalize existing configurations.

Expand Down Expand Up @@ -568,8 +583,13 @@ uvx cloudX-proxy cleanup --ssh-config ~/.ssh/custom/config
```

**Prefix Normalization:** The cleanup command normalizes all host patterns and ProxyCommand references to match the command name used:
- Running `cloudX-proxy cleanup` converts `Host cloudx-*` → `Host cloudX-*` and `uvx cloudx-proxy` → `uvx cloudX-proxy`
- Running `cloudx-proxy cleanup` converts `Host cloudX-*` → `Host cloudx-*` and `uvx cloudX-proxy` → `uvx cloudx-proxy`
- Running `cloudX-proxy cleanup` converts `Host cloudx-*` → `Host cloudX-* cloudx-*` and `uvx cloudx-proxy` → `uvx cloudX-proxy`
- Running `cloudx-proxy cleanup` converts `Host cloudX-*` → `Host cloudx-* cloudX-*` and `uvx cloudX-proxy` → `uvx cloudx-proxy`

Wildcard blocks keep both spellings so that host entries in either case pick up
`User`, `IdentitiesOnly` and the `ProxyCommand`. Host entries are **not** renamed:
what an instance is called belongs to whoever created it. Re-running `setup` for a
host that already exists updates its instance id and leaves its name alone.

This allows users to easily convert between naming conventions. The preferred convention is `cloudX` (uppercase X).

Expand Down Expand Up @@ -675,7 +695,9 @@ These permissions are required to bootstrap the instance, so that after creation
- **Region mismatch** - Ensure AWS profile region matches instance location

4. **SSH Key Issues**
- If using 1Password SSH agent, verify agent is running (~/.1password/agent.sock exists)
- If using 1Password SSH agent, verify the agent is running. Where it lives depends on the platform:
* macOS/Linux: `~/.1password/agent.sock` exists (Linux snap installs: `~/snap/1password/current/.1password/agent.sock`)
* Windows: 1Password serves the standard OpenSSH named pipe `\\.\pipe\openssh-ssh-agent`, which ssh uses by default - there is no socket file and no `IdentityAgent` line is written
- Check file permissions (600 for private key, 644 for public key)
- Verify the public key is being successfully pushed to the instance
- For 1Password-managed keys, make sure:
Expand Down
45 changes: 32 additions & 13 deletions cloudx_proxy/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,20 @@ def detect_ssh_defaults() -> tuple:
return "cloudX", "cloudX", "~/.ssh/cloudX"


def prefix_from_command_name() -> str:
"""The host prefix implied by the command name that was invoked.

On Windows a console script is an .exe, so sys.argv[0] ends in one and a
bare basename never equals 'cloudX-proxy' - every Windows user silently
got the lowercase prefix whichever command they typed. Compare the stem.

Returns:
str: 'cloudX' when invoked as cloudX-proxy, otherwise 'cloudx'
"""
stem = os.path.splitext(os.path.basename(sys.argv[0]))[0]
return 'cloudX' if stem == 'cloudX-proxy' else 'cloudx'


def short_host_name(host: str, host_prefix: str) -> str:
"""Strip '<prefix>-<env>-' off a host entry to get its short name.

Expand Down Expand Up @@ -188,8 +202,7 @@ def setup(profile: str, ssh_key: str, ssh_config: str, ssh_dir: str, aws_env: st
try:
# Determine default prefix based on command name if not provided
if not ssh_host_prefix:
cmd_name = os.path.basename(sys.argv[0])
ssh_host_prefix = 'cloudX' if cmd_name == 'cloudX-proxy' else 'cloudx'
ssh_host_prefix = prefix_from_command_name()

setup = CloudXSetup(
profile=profile,
Expand All @@ -205,9 +218,9 @@ def setup(profile: str, ssh_key: str, ssh_config: str, ssh_dir: str, aws_env: st
)

if dry_run:
print(f"\n{header(f'=== {ssh_host_prefix}-proxy Setup (DRY RUN) ===')}\n")
print(f"\n{header('=== cloudX-proxy Setup (DRY RUN) ===')}")
else:
print(f"\n{header(f'=== {ssh_host_prefix}-proxy Setup ===')}\n")
print(f"\n{header('=== cloudX-proxy Setup ===')}")

# Report missing tooling up front rather than from inside a
# ProxyCommand, where the error is easy to miss
Expand Down Expand Up @@ -340,7 +353,7 @@ def list(ssh_config: str, environment: str, detailed: bool, dry_run: bool):
config_file = cloudx_config

if dry_run:
print(f"\n{header('=== cloudx-proxy List (DRY RUN) ===')}\n")
print(f"\n{header('=== cloudX-proxy List (DRY RUN) ===')}\n")
print(f"[DRY RUN] Would read SSH config from: {config_file}")
if environment:
print(f"[DRY RUN] Would filter hosts by environment: {environment}")
Expand All @@ -355,8 +368,7 @@ def list(ssh_config: str, environment: str, detailed: bool, dry_run: bool):
sys.exit(1)

# Detect ssh_host_prefix from command name
cmd_name = os.path.basename(sys.argv[0])
ssh_host_prefix = 'cloudX' if cmd_name == 'cloudX-proxy' else 'cloudx'
ssh_host_prefix = prefix_from_command_name()

# Use shared parser from CloudXSetup
setup = CloudXSetup(ssh_config=str(config_file), ssh_host_prefix=ssh_host_prefix)
Expand All @@ -367,17 +379,25 @@ def list(ssh_config: str, environment: str, detailed: bool, dry_run: bool):
environments = {}
generic_hosts = []

# Patterns are shown in the preferred spelling whichever command name
# was typed. A configured host keeps whatever case its owner gave it
# and is listed under that name; a pattern has no owner, so there is
# no reason to show 'cloudx-dev-*' to someone who ran cloudX-proxy.
display_prefix = setup.preferred_host_prefix

# Add global pattern if present
if parsed['global']:
generic_hosts.append((f"{ssh_host_prefix}-*", "N/A"))
generic_hosts.append((f"{display_prefix}-*", "N/A"))

# Process each environment
for env_key, env_data in parsed['environments'].items():
# Use original case name for display, fallback to key if not present
display_name = env_data.get('name', env_key)

# Add environment pattern to generic hosts
generic_hosts.append((env_data['pattern'], "N/A"))
# Add environment pattern to generic hosts. Environments with no
# hosts of their own appear here and nowhere else, since the
# listing below is built from host entries.
generic_hosts.append((f"{display_prefix}-{display_name}-*", "N/A"))

# Filter by environment if specified
if environment and env_key.lower() != environment.lower():
Expand Down Expand Up @@ -426,7 +446,7 @@ def list(ssh_config: str, environment: str, detailed: bool, dry_run: bool):
return

# Print header
print(f"\n{header('=== cloudx-proxy Configured Hosts ===')}\n")
print(f"\n{header('=== cloudX-proxy Configured Hosts ===')}\n")

# Print generic patterns if any and detailed mode
if generic_hosts and detailed:
Expand Down Expand Up @@ -514,8 +534,7 @@ def cleanup(ssh_config: str, ssh_host_prefix: str, dry_run: bool):

# Determine default prefix based on command name if not provided
if not ssh_host_prefix:
cmd_name = os.path.basename(sys.argv[0])
ssh_host_prefix = 'cloudX' if cmd_name == 'cloudX-proxy' else 'cloudx'
ssh_host_prefix = prefix_from_command_name()

setup = CloudXSetup(ssh_config=ssh_config, ssh_host_prefix=ssh_host_prefix, dry_run=dry_run)

Expand Down
Loading