aenvs/.docs/01-opus-docs/01-sprint.md

361 lines
9.4 KiB
Markdown

# AEVS CLI Tool — Sprint 1 Specification
## Overview
CLI tool for syncing `.env` variables between machines and team members.
- **Language:** Go (golang)
- **Architecture:** CLI communicates with a REST API server
- **Sprint scope:** CLI implementation with mock server interface
---
## Commands
### `aevs init`
Initializes or updates project configuration.
```bash
aevs init
```
**Behavior (new config):**
1. Interactive prompt for `api_url`
2. Interactive prompt for `api_key`
3. Runs `parseLocals` to scan for env files
4. Creates `aevs.yaml` config with found environments
5. `project` field left empty (will be set on first sync)
**Behavior (config exists):**
1. Runs `parseLocals` to scan for new env files
2. Merges newly found files into existing config
3. Displays list of added files
---
### `aevs project`
Project management commands.
#### List projects
```bash
aevs project -l, --list
```
**Output:**
```
uuid | name
11111111-1111-1111-1111-0123456789ab | my-project
22222222-2222-2222-2222-0123456789ab | another-project
```
#### Update project name
```bash
aevs project save -n, --name "new_name"
```
**Optional flags:**
- `-c, --config <path>` — config path to read `project_id`
- `-i, --id <uuid>` — project ID directly (without config)
#### Delete project
```bash
aevs project remove -r, --id <uuid>
```
**Behavior:**
1. Fetches project name from server
2. Prompts user to confirm:
```
To delete project "my-project", type the name exactly: my-project
> _
```
3. If input matches → removes project and all versions from server
4. If input doesn't match → abort with error
#### Read project versions
```bash
aevs project read --id <uuid>
aevs project read --name <name>
```
**Output:**
```
timestamp | version_name | creator | branch | state
1706000000000000000 | v1.0 | root | main | active
- ./envs/.env.prod
- ./envs/.env.dev
1705900000000000000 | initial | root | feature/xyz | inactive
- ./envs/.env.prod
```
---
### `aevs sync`
Synchronizes local environment files with server.
```bash
aevs sync [-c, --config <path>]
```
**Flags:**
- `-c, --config <path>` — path to config (default: `./aevs.yaml`)
**Flow:**
1. Parse config via `parseConfig`
2. If `project` is empty:
- Generate UUID on client (`uuid.New()`)
- Prompt for project name
- Create project on server via `POST /projects` with generated ID
- Save `project` ID to config
3. Read local env files (paths from config only, no scanning)
4. Request version data from server:
- If config version is inactive → server returns latest **active** version
5. Build diff map between local and remote
6. Interactive conflict resolution:
```
ENVIRONMENT: .env.prod
VARIABLE: DATABASE_URL
[1] local: postgres://localhost:5432/dev
[2] remote: postgres://prod.db:5432/prod
Select (1/2):
```
7. New variables from remote are added without prompts
8. **Always create new version** (never update existing)
9. Prompt for new version name
10. Update local files and push new version to server
11. Update `version` in config
**Edge case — no changes:**
- If local = remote → message: "Already up to date" (no new version created)
**Edge case — no active version exists:**
- If local files exist → create new version (prompt for name)
- If no local files → error: "No active versions and no local env files found"
---
### `aevs merge`
Merges multiple versions into one.
```bash
aevs merge -v, --version "ts1 ts2 ts3" [-n, --name "merged"] [-c, --config <path>] [-p, --project <uuid>]
```
**Flags:**
- `-v, --version <timestamps>`**required**, space-separated list of version timestamps
- `-n, --name <name>` — new version name (default: current timestamp)
- `-c, --config <path>` — config path to read `project_id`
- `-p, --project <uuid>` — project ID directly
**Note:** At least one of `-c` or `-p` must be provided.
**Flow:**
1. Fetch all specified versions from server
2. Interactive conflict resolution (same UX as sync)
3. Prompt for branch name (default: current git branch via `git symbolic-ref --short HEAD`)
4. Create new active version
5. Mark all source versions as `inactive`
---
## Functions
### `parseConfig`
Reads `aevs.yaml` configuration file.
**Input:** File path (or `DEFAULT_CONFIG`)
**Returns:**
- `project_id` — uuid.UUID (empty for new projects, set on first sync)
- `version_id` — int64 (unix nanoseconds)
- `api_key` — string
- `api_url` — string (server base URL)
- `environments` — []string (list of file paths)
### `parseLocals`
Scans directory tree for env files. **Only used during `aevs init`.**
**Patterns (include):**
- `.env` — exact match
- `*.env.*` — e.g., `.env.prod`, `config.env.local`
- `.env.example`, `.env.sample` — included (not ignored)
**Patterns (ignore directories):**
- `node_modules/`
- `.git/`
- `vendor/`
**Returns:** `map[string]map[string]string`
- Key: relative file path (e.g., `./docker/.env.prod`)
- Value: parsed KEY=VALUE pairs
**Note:** After config exists, only paths listed in config are used. Re-running `init` merges new files.
---
## Configuration
**File:** `aevs.yaml` (or path specified via `-c, --config`)
```yaml
api_url: "https://api.aevs.io"
api_key: "your-api-key-here"
project: "11111111-1111-1111-1111-0123456789ab" # empty until first sync
version: 1706000000000000000 # unix nanoseconds
environments:
- "./envs/.env.prod"
- "./envs/.env.dev"
- "./.env.local"
```
**Rules:**
- Single config file per project (no inheritance)
- Path is either explicit (`-c` flag) or current directory
- `project` may be empty initially (created on first sync)
---
## Types
```go
package types
import "github.com/google/uuid"
type Project struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
}
type State int
const (
Active State = 1
Inactive State = -1
)
type Version struct {
Ts int64 `json:"ts"` // unix nanoseconds (time.Now().UnixNano())
Name string `json:"name"`
Creator string `json:"creator"` // TODO: from api_key user, hardcode "root" for now
Branch string `json:"branch"`
State State `json:"state"`
Envs []Env `json:"envs"`
}
type Env struct {
Path string `json:"path"` // relative file path = identifier (e.g., "./.env.prod")
Vars map[string]string `json:"vars"` // KEY=VALUE pairs (all strings, as in .env files)
}
```
**Note on Env.Vars:** All values are stored as strings because `.env` files only support string values. Type interpretation (number, bool, JSON) is handled by the application reading the env file.
---
## Server API Interface
**Base URL:** Configurable via `api_url` in config
**Authentication:** `Authorization: Bearer {api_key}`
**Content-Type:** `application/json`
### Endpoints
| Method | Path | Description | Request | Response |
|--------|------|-------------|---------|----------|
| GET | `/projects` | List projects (only accessible to api_key) | — | `[]Project` |
| POST | `/projects` | Create project | `{id, name}` | `Project` |
| PATCH | `/projects/{id}` | Update project name | `{name}` | `Project` |
| DELETE | `/projects/{id}` | Delete project | — | `204` |
| GET | `/projects/{id}/versions` | List versions | — | `[]Version` |
| POST | `/projects/{id}/versions` | Create version | `Version` | `Version` |
| GET | `/projects/{id}/versions/{ts}` | Get version with envs | — | `Version` |
| PATCH | `/projects/{id}/versions/{ts}` | Update version state | `{state}` | `Version` |
### Error Response Format
```json
{
"error": "Human-readable error message",
"code": "ERROR_CODE"
}
```
**Error codes:**
- `PROJECT_NOT_FOUND`
- `PROJECT_ALREADY_EXISTS` — when POST /projects with existing ID
- `VERSION_NOT_FOUND`
- `UNAUTHORIZED`
- `VALIDATION_ERROR`
- `INTERNAL_ERROR`
### Mock Server
For development and testing, implement mock server that:
- Stores data in memory
- Implements all endpoints above
- Validates api_key (hardcoded keys for MVP)
- **Authorization isolation:** Pre-populate with test data for different api_keys to verify users can't see other users' projects
**Test setup:**
```go
// Mock data for testing authorization
mockData := map[string][]Project{
"api-key-user-1": {
{ID: uuid.MustParse("..."), Name: "user1-project"},
},
"api-key-user-2": {
{ID: uuid.MustParse("..."), Name: "user2-project"},
},
}
// GET /projects with "api-key-user-1" must NOT return "user2-project"
```
---
## Error Handling
| Scenario | Behavior |
|----------|----------|
| No config found | Error: "Config not found. Run `aevs init` first." |
| Network failure | Error with message, preserve local state |
| Invalid api_key | Error: "Authentication failed" |
| Project not found | Error: "Project {id} not found" |
| Version not found | Error: "Version {ts} not found" |
---
## Git Integration
- **Branch tracking:** Before sync/merge, run `git symbolic-ref --short HEAD`
- **Purpose:** Visual indicator of which git branches used this version
- **Merge behavior:** Current branch overwrites version branch (with optional custom input)
---
## Concurrency
- **Concurrent sync:** Multiple users syncing simultaneously will each create their own new versions
- **No locking:** No optimistic/pessimistic locking mechanism
- **Resolution:** Conflicts between versions are resolved via `aevs merge`
---
## Constants
```go
const (
DefaultConfig = "aevs.yaml"
DefaultAPIURL = "https://api.aevs.io"
DefaultCreator = "root" // TODO: get from api_key user info
)
```