This commit is contained in:
naudachu 2026-01-28 16:24:21 +05:00
commit e10b389661
38 changed files with 5909 additions and 0 deletions

View File

@ -0,0 +1,124 @@
# cli tool
We're going to make a tool which objective is to sync .env variables between different machines and team members; Предполагается, что cli общается с сервером, имплементация которого остаётся за рамками этого спринта. При этом, нужно понимать, что сервер существует и для упрощения дальнейшей разработки задать вопросы и подготовить документацию для дальнейшего кодирования сервера. Сам сервер мы не проектируем, но описываем интерфейс для работы с ним, чтобы сервер можно было замокать и тестировать CLI приложение.
Язык разработки golang.
## Stories
### Read projects list
cmd: `aevs project -l` (or `--list`)
shows project's list from the server
```md
uuid | name
```
### Update project (project's name)
cmd: `aevs project save -n "project_name"` (or `--name`)
optional:
- `-c, --config` to identify config path to use parseConfig to get project variable (project_id);
- `-i --id` to identify project's id w/o reading config;
### Delete project
cmd: `aevs project remove --id {uuid.UUID}` (or `-r`)
removes project's envs from the server;
asks user to type project's name to proof that user really wants to delete project from the server;
### Read remote project's environments
cmd: by uuid: `aevs project read --id {uuid.UUID}` (or `--read-id`)
cmd: by name: `aevs project read --name {name}` (or `--read-name`)
shows project's environment's list w/ version indentation:
f.e.:
```md
version_creation_timestamp | version_name | creator | branch_name | active/inactive
- production | relative_path
- development | relative_path
- local | relative_path
```
### Sync project's environments by config
cmd:
`aevs sync`
- `-c, --config [FILE_PATH]` identifies path to config to use with `parseConfig`,
if not provided tries to read `DEFAULT_CONFIG` path;
fails w/ an error "no config found" if no config found;
- THEN uses `parseLocals` to read local env files
- THEN makes server request to retrieve version's environments (listed at the config only);
- if version is inactive, server responds w/ latest version data;
- THEN makes diffs map
- THEN recursively asks what environments to save at the local files (new environments should be added to a local map w/o questions)
here's two options:
- result files creates new version (updates local env files and saves new version at the server);
### Merge versions
cmd:
`aevs merge`
mandatory flags:
- `-v, --version` - the list of space separate versions (timestamp) to be merged into a single one
optional flag)
- `-n, --name` - new version's name
Gets version's environments and asks user to select which version is correct;
If name is not provided - set timestamp as a version's name;
All merged versions becomes `inactive`;
## Functions
### parseConfig
Parse provided directory and search for a config file (file path should be provided or `DEFAULT_CONFIG` path would be used). Reads this file to retrieve:
- project_id (uuid.UUID);
- version_id (timestamp);
- api_key (string);
- active environment's names and paths to it's files
```yaml
project: "11111111-1111-1111-1111-0123456789ab"
api_key: "this-is-my-key"
version: "12341234"
environments:
production:
path: "production_env_path"
development:
path: "dev_env_path"
```
### parseLocals
Parse current directory and all subdirectories, then searches for all files with `*.env.*` naming;
Creates `map[K]map[string]any` object where:
- K -- environment variables file name, f.e. `./docker/.env.prod` --> `.env.prod`
## Environments:
DEFAULT_CONFIG = `aevs.yaml`
## Types
```golang
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 timestamp;
Name string `json:"name"`
Creator string `json:"creator"`
Branch string `json:"branch"`
State State `json:"state"`
Envs []Env `json:"envs"`
}
type Env struct {
Name string `json:"name"`
Path string `json:"path"`
}
```

View File

@ -0,0 +1,47 @@
## Unclear Functionality Questions
7. **Version semantics:**
- What is relationship between `branch_name` and `version`?
we're making `git symbolic-ref --short HEAD` to inspect current branch before version sync and add current branch to the existed one on the server this field if the branch was changes.
it's just a visual strings to identify on which git branches this version was used, so it's easier to the user to decide what version's is needed;
while merge operation user's current branch overrides version's branch with an interactive prompt, that lets user to use current one or write a string with a custom one
8. **Sync behavior:**
- "if version is inactive, server responds w/ latest version data" — what if NO active version exists?
Answer: provide some variants
- "recursively asks what environments to save" — what's the UX flow here? Interactive prompts?
Answer: interactive prompts, f.e.:
```
ENVIRONMENT NAME:
VARIABLE NAME:
version timestamp 1: -
version timestamp 2: WABALABUDABDAB
version timestamp 3: BABRAKADABRA
please enter the number of the valid VAR for a new version;
```
9. **Merge behavior:**
- What happens to source versions after merge? Deleted? Marked inactive?
Answer: source versions should be marked as inactive and cannot be updated anymore;
- `-v, --version` takes "list of versions" — what format? Comma-separated? Multiple flags?
Answer: just space separated list of version's timestamps;
10. **Config scope:**
- Is `api_key` per-project or can it be global (e.g., in `~/.config/aevs/`)?
Answer: `api_key`'s scope is a user's scope; user management would be implemented later
- Can config inherit from parent directories?
Answer: do not understand the question;
---
## Missing Information
11. **Error handling:** What happens on network failure during sync/merge?
Answer: failing error w/ the previous state of the entities;
12. **Authentication:** How is api_key validated? What permissions does it grant?
Answer: for now let api_key be a hardcoded value, we'd create web server later;
13. **Conflict resolution:** In sync, what if local and remote have conflicting changes?
Answer: We've previously discussed merging conflicts flow. If it's not a merge operation we're going through a conflict resolution flow, but there'd be new version created as local, as at the server;

View File

@ -0,0 +1,131 @@
# Follow-up Questions (q-002)
## Typos / Bugs in Types Definition
В типах есть ошибки, которые нужно исправить:
```golang
// Текущий код с ошибками:
type Project struct {
Id uuid.UUID `json:"id"`
Name string `json:"string"` // ❌ должно быть json:"name"
}
const { // ❌ должно быть const (
ACTIVE State = 1
INACTIVE State = -1
}
type Version struct {
Ts int64 `json:"td"` // ❌ опечатка, должно быть json:"ts"
// ...
}
type Envs struct { // ❌ должно быть Env (singular), т.к. используется как []Env
Name string `json:"name"`
Path string `json"path"` // ❌ пропущено двоеточие, json:"path"
}
```
**Подтверди исправления или укажи правильные значения.**
---
## Уточнения по командам
1. **`aevs project remove --id {uuid}` имеет комментарий `(or --remove)`:**
- `--remove` не эквивалентен `--id`
- Имелось в виду `-r` как короткий флаг для `remove`? Или это описка?
2. **`aevs project read` — нет флага для вывода конкретной версии:**
- Сейчас показывает все версии проекта
- Нужен ли флаг `--version {timestamp}` для просмотра конкретной версии?
Answer: не нужен;
---
## Уточнения по sync
3. **"if version is inactive, server responds w/ latest version data":**
- "Latest" означает:
- (a) последнюю по timestamp **активную** версию?
- (b) последнюю созданную версию (независимо от state)?
Answer: (a)
4. **При sync создаётся новая версия — откуда берётся `version_name`?**
- Автоматически (timestamp)?
- Интерактивный prompt пользователю?
- Из конфига?
Answer: интерактивный промпт
5. **Version.Creator — откуда берётся значение?**
- Из api_key?
- Из git config user.name?
- Интерактивный ввод?
Answer: api_key подразумевает конкретного пользователя с набором пермишнов и username, так вот Version.Creator = username, на данный момент можно захардкодить `root` с пометкой // TODO
---
## Уточнение по "no active version"
6. **В q-001 ответ "provide some variants" не ясен.**
Варианты поведения при sync, если нет активной версии:
- (a) Создать новую версию из локальных файлов
- (b) Показать ошибку "no active version found"
- (c) Показать список всех версий и предложить выбрать
**Какой вариант предпочтителен?**
Answer: Если локальные файлы есть, то нужно сформировать новую версию с вопросом о её имени; если нет и локальных файлов окружения нужно вывести ошибку, что нет активных версий и локальных версий тоже. Ситуация кажется невозможной, т.к. версии можно сделать неактивными только в результате мерджа, при этом операция подразумевает создание новой активной версии;
---
## Server Interface
7. **Какой протокол для сервера?**
- REST API (HTTP/JSON)?
- gRPC?
- Другое?
Answer: рассмотри другие cli tool такого рода, например wrangler для cloudflare
8. **Нужно ли описать endpoints сервера для мока?**
Например:
```
GET /projects
POST /projects
DELETE /projects/{id}
GET /projects/{id}/versions
POST /projects/{id}/versions
PATCH /projects/{id}/versions/{ts}
```
Answwer: нам нужен мок веб. сервера, но не сам вебсервер, нужно сделать для этого всё необходимое;
---
## parseLocals vs Config.environments
9. **Как соотносятся parseLocals и environments из конфига?**
- parseLocals сканирует все `*.env.*` файлы в директории
- Config.environments содержит маппинг `name → path`
**Вопрос:** При sync используем только environments из конфига, или parseLocals тоже участвует? Если оба — как разрешаются конфликты имён?
Answer: имена могут быть не уникальными, конфиг заполняется из parseLocals, для добавления новых locals в конфиг нужно сделать это явно, т.е. если конфиг существет, то мы больше не ищем файлы в директориях проекта
---
## Пояснение по "config inheritance"
10. **Что я имел в виду под "inherit from parent directories":**
Пример структуры:
```
~/projects/
aevs.yaml ← глобальный конфиг (api_key)
my-app/
aevs.yaml ← проектный конфиг (project, version, environments)
```
При запуске `aevs sync` в `~/projects/my-app/`:
- Читается `my-app/aevs.yaml`
- Если `api_key` не указан, ищется в `../aevs.yaml`
**Нужна ли такая иерархия? Или api_key всегда должен быть в проектном конфиге?**
Answer: конфиг только один, путь до него либо указан флагом, либо он в директории из которой вызывается утилита, наследование не нужно

View File

@ -0,0 +1,116 @@
# Follow-up Questions (q-003)
## Структура данных для переменных окружения
3. **Тип `Env` содержит только `Name` и `Path`:**
```golang
type Env struct {
Name string `json:"name"`
Path string `json:"path"`
}
```
Но `parseLocals` возвращает `map[K]map[string]any` — т.е. `filename → (key → value)`.
**Вопрос:** Где хранятся сами переменные (key-value pairs)?
- (a) Нужен отдельный тип `EnvVar` с ключом и значением?
- (b) Добавить поле `Vars map[string]string` в `Env`?
- (c) Переменные хранятся только на сервере, `Env` — только metadata?
Answer: Переменные окружения бывают не только строками, учти это и создай подходящую структуру;
---
## Создание проекта
4. **Как создаётся новый проект?**
- `project save -n` — обновляет имя существующего проекта
- `project remove` — удаляет проект
- **Нет команды для создания проекта**
Варианты:
- (a) `aevs project create -n "name"` — отдельная команда
- (b) `aevs sync` создаёт проект автоматически, если `project_id` в конфиге пустой
- (c) Проекты создаются только через веб-интерфейс сервера
Answer: (b)
---
## Инициализация конфига
5. **Как создаётся `aevs.yaml` впервые?**
- `aevs sync` без конфига → ошибка "no config found"
Варианты:
- (a) Пользователь создаёт вручную
- (b) Команда `aevs init` создаёт шаблон конфига
- (c) `aevs init` + интерактивные промпты (project name, api_key, scan for env files)
Answer: (c)
---
## Merge: контекст проекта
6. **Команда `aevs merge -v "ts1 ts2"` — как определяется проект?**
- Сейчас нет `-c, --config` флага в описании merge
- Версии принадлежат конкретному проекту
**Нужно добавить:**
- `-c, --config` для чтения `project_id` из конфига?
- Или `-p, --project {uuid}` для явного указания?
Answer: Нужно добавить оба флага как опциональные, если ни одного нет выводить ошибку
---
## Sync: "save into current version"
7. **В sync описаны два варианта:**
> - result files creates new version
> - result files should be saved into current version
**Вопрос по второму варианту:**
- "Save into current version" = обновление существующей версии in-place?
- Не создаст ли это конфликты, если другой пользователь уже синхронизировался с этой версией?
- Или имеется в виду что-то другое?
Answer: да, ты прав, это создаст конфликты, поэтому всегда создавай новые версии, пока их кто-то не смерджит;
---
## Wrangler reference
8. **По поводу wrangler:**
Изучил — Cloudflare wrangler использует REST API с Bearer token authentication.
**Предлагаю для aevs:**
```
Base URL: configurable (e.g., https://api.aevs.io)
Auth: Header `Authorization: Bearer {api_key}`
Format: JSON
Endpoints:
GET /projects → []Project
POST /projects → Project (create)
PATCH /projects/{id} → Project (update name)
DELETE /projects/{id} → void
GET /projects/{id}/versions → []Version
POST /projects/{id}/versions → Version (create)
GET /projects/{id}/versions/{ts} → Version (with full env data)
PATCH /projects/{id}/versions/{ts} → Version (update state/merge)
```
**Подходит такой интерфейс?**
Answer: да;
---
## Server Base URL
9. **Где хранится URL сервера?**
- В конфиге `aevs.yaml`?
- В environment variable (e.g., `AEVS_API_URL`)?
- Захардкожен для MVP?
Answer: давай указывать его в конфиге;

View File

@ -0,0 +1,360 @@
# 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
)
```

View File

@ -0,0 +1,94 @@
# Remaining Questions (02-q)
## EnvValue type design
1. **Предложенный тип `EnvValue`:**
```go
type EnvValue struct {
Type string `json:"type"` // "string", "number", "bool", "json"
Value any `json:"value"`
}
```
**Вопросы:**
- Достаточно ли типов: `string`, `number`, `bool`, `json`?
- Или нужны дополнительные (например, `array`, `secret`)?
- Нужно ли шифрование для sensitive values (passwords, tokens)?
Answer: нужны все типы для того чтобы создать валидный .env файл на их основе
---
## Config creation during init
2. **При `aevs init` — что делать если `aevs.yaml` уже существует?**
- (a) Ошибка: "Config already exists"
- (b) Перезаписать с предупреждением
- (c) Merge: добавить новые env файлы в существующий конфиг
Answer: (c) если эта логика не предусмотрена в другой команде, (a) если у нас уже существует команда для добавления новых env файлов
---
## Version timestamp uniqueness
3. **Timestamp как ID версии:**
- Unix timestamp в секундах может не быть уникальным (два пользователя создают версию в одну секунду)
- Использовать миллисекунды?
- Или сервер должен гарантировать уникальность и возвращать корректный ts?
Answer: используй nanosec
---
## Sync without changes
4. **Что делать если при sync нет изменений (local = remote)?**
- (a) Сообщение "Already up to date" и ничего не делать
- (b) Всё равно создавать новую версию
- (c) Опционально через флаг `--force`
Answer: (a)
---
## Delete confirmation
5. **При `aevs project remove` — как именно подтверждать удаление?**
- Ввести полное имя проекта?
- Ввести "DELETE" или подобное ключевое слово?
- Флаг `--force` для skip confirmation?
Answer: ввести полное имя проекта (в промпте напиши его, чтобы нужно было перепечатать просто)
---
## parseLocals edge cases
6. **Какие файлы считать env файлами?**
- Только `*.env.*` или также просто `.env`? --> `.env` тоже;
- Игнорировать `.env.example`, `.env.sample`? --> не игнорировать;
- Игнорировать файлы в `node_modules/`, `.git/`? --> игнорировать;
---
## API error responses
7. **Формат ошибок от сервера:**
```json
{
"error": "Project not found",
"code": "PROJECT_NOT_FOUND"
}
```
Или другой формат?
Answer: этот формат норм
---
## Concurrent sync
8. **Если два пользователя делают sync одновременно:**
- Оба создадут новые версии?
- Нужен ли механизм блокировки (optimistic locking)?
- Или это решается на уровне merge?
Answer: оба создают новые версии;

View File

@ -0,0 +1,79 @@
# New Questions (03-q)
## Environment naming during init
1. **parseLocals находит файлы типа `.env.prod`, `./docker/.env.local`**
В конфиге environments имеет структуру:
```yaml
environments:
production: # <-- это Env.Name
path: ".env.prod" # <-- это Env.Path
```
**Вопрос:** Откуда берётся `Name` ("production")?
- (a) Интерактивный промпт для каждого найденного файла
- (b) Автоматически из имени файла (`.env.prod` → `prod`)
- (c) Используем путь к файлу как Name (`.env.prod` → `.env.prod`)
answer (c)
---
## Merge с 3+ версиями
2. **При merge трёх версий, где переменная имеет разные значения:**
```
VARIABLE: DATABASE_URL
[1] version 1706000001: postgres://v1
[2] version 1706000002: postgres://v2
[3] version 1706000003: postgres://v3
Select (1/2/3):
```
**Это правильное поведение?** Или merge должен быть только для двух версий за раз?
answer: правильное
---
## api_url scope
3. **`api_url` в конфиге проекта:**
- Разные проекты могут иметь разные `api_url`?
- Или `api_url` должен быть глобальным (env var `AEVS_API_URL`)?
**Вопрос:** Какой scope у api_url?
- (a) Per-project (в `aevs.yaml`)
- (b) Global (env var или `~/.config/aevs/config.yaml`)
- (c) Both: global default + per-project override
answer (a)
---
## Project name timing
4. **Когда устанавливается имя проекта?**
Сценарий:
1. `aevs init` — промпт "Enter project name"
2. `aevs sync` — если project_id пустой, создаём проект на сервере
**Вопрос:** Имя проекта:
- (a) Сохраняется в конфиге во время init, используется при создании на сервере
- (b) Запрашивается только при первом sync
- (c) В конфиге нет имени, только project_id после первого sync
answer: (b), id проекта -- это uuid его можно сгенерировать на клиенте
---
## GET /projects authorization
5. **`GET /projects` возвращает все проекты:**
- Все проекты в системе?
- Только проекты доступные этому api_key?
**Для MVP:** Один api_key = доступ ко всем проектам?
только доступные проекты (для mvp - создай в inmemory хранилище проекты для другого ключа, чтобы на тестах убедиться, что пользователь его не видит)

View File

@ -0,0 +1,42 @@
# Edge Case Questions (04-q)
## Merge: variable exists in some versions but not others
1. **При merge 3 версий:**
```
version 1: DATABASE_URL=postgres://v1, REDIS_URL=redis://r1
version 2: DATABASE_URL=postgres://v2
version 3: DATABASE_URL=postgres://v3, REDIS_URL=redis://r3
```
Для `REDIS_URL` — version 2 не имеет этой переменной.
**Как отображать?**
- (a) `[2] version 2: <not set>`
- (b) Показывать только версии где переменная есть
- (c) Автоматически добавлять переменную если она есть хотя бы в одной версии
---
## aevs project --list: откуда api_key?
2. **Команда `aevs project -l` не требует `-c, --config`:**
- Откуда брать `api_url` и `api_key` для запроса к серверу?
**Варианты:**
- (a) Всегда требовать `-c` или читать из `./aevs.yaml`
- (b) Добавить флаги `--api-url` и `--api-key` напрямую
- (c) Использовать env vars `AEVS_API_URL`, `AEVS_API_KEY` как fallback
---
## First sync: no local files
3. **Первый sync когда нет локальных файлов:**
- Config создан через `aevs init`, но env файлы ещё не существуют (пустой список)
- Или файлы были удалены после init
**Поведение:**
- (a) Ошибка: "No environment files found in config"
- (b) Создать пустую версию на сервере
- (c) Предложить запустить `aevs init` заново

View File

@ -0,0 +1,109 @@
# AEVS CLI — Simplified Specification
## Overview
CLI-инструмент для синхронизации `.env` файлов между машинами одного пользователя.
- **Язык:** Go (golang)
- **Storage:** S3-совместимое хранилище (AWS S3, MinIO, Cloudflare R2, DigitalOcean Spaces)
- **Целевой сценарий:** Один пользователь, несколько машин
---
## Проблема
Разработчик работает над проектами на нескольких машинах (ноутбук, рабочий ПК, сервер). Код синхронизируется через Git, но `.env` файлы:
- Не должны попадать в Git (секреты)
- Нужно как-то переносить между машинами
- Ручное копирование — неудобно и ненадёжно
---
## Решение
Простой CLI с двумя основными командами:
- `aevs push` — загрузить локальные `.env` файлы в облачное хранилище
- `aevs pull` — скачать `.env` файлы из хранилища
Проекты идентифицируются по имени. Никаких версий, merge, конфликтов между пользователями.
---
## Архитектура
```
┌──────────────┐ ┌──────────────┐
│ Machine A │ │ Machine B │
│ (laptop) │ │ (office) │
│ │ ┌─────────┐ │ │
│ project/ │ │ Storage │ │ project/ │
│ .env.prod │ ───► │ (S3) │ ◄── │ (empty) │
│ .env.dev │ │ │ │ │
│ aevs.yaml │ └─────────┘ │ │
└──────────────┘ └──────────────┘
│ │
▼ ▼
aevs push aevs pull
```
---
## Структура данных в Storage
```
s3://my-aevs-bucket/
├── my-awesome-project/
│ ├── envs.tar.gz # архив с .env файлами
│ └── metadata.json # метаданные (время, список файлов)
├── another-project/
│ ├── envs.tar.gz
│ └── metadata.json
└── old-project/
├── envs.tar.gz
└── metadata.json
```
---
## Ключевые принципы
1. **Простота** — минимум команд, минимум конфигурации
2. **Один владелец** — нет конфликтов между пользователями
3. **Последний push побеждает** — нет merge, нет версий
4. **S3 как источник правды** — если нужна история, используй S3 versioning
5. **Безопасность через S3** — приватный bucket, IAM, encryption at rest
---
## Scope
### В scope
- ✅ Инициализация проекта (`init`)
- ✅ Загрузка файлов в storage (`push`)
- ✅ Скачивание файлов из storage (`pull`)
- ✅ Просмотр списка проектов (`list`)
- ✅ Настройка credentials (`config`)
- ✅ Автоматическое сканирование `.env*` файлов
### Вне scope
- ❌ Версионирование (полагаемся на S3 versioning)
- ❌ Merge конфликтов
- ❌ Многопользовательский доступ
- ❌ REST API сервер
- ❌ Шифрование на клиенте
- ❌ Интерактивное разрешение конфликтов
---
## Зависимости
```go
require (
github.com/aws/aws-sdk-go-v2/config
github.com/aws/aws-sdk-go-v2/service/s3
github.com/spf13/cobra
gopkg.in/yaml.v3
)
```

View File

@ -0,0 +1,179 @@
# Configuration
## Два уровня конфигурации
AEVS использует два конфигурационных файла:
1. **Глобальный** — credentials для доступа к storage (один на машину)
2. **Локальный** — настройки конкретного проекта (один на проект)
---
## Глобальный конфиг
**Путь:** `~/.config/aevs/config.yaml`
**Права доступа:** `0600` (только владелец может читать/писать)
**Содержимое:**
```yaml
storage:
type: s3 # тип хранилища (пока только s3)
endpoint: https://s3.amazonaws.com # S3 endpoint (для MinIO — свой URL)
region: us-east-1 # AWS region (опционально)
bucket: my-aevs-bucket # имя bucket
access_key: AKIAIOSFODNN7EXAMPLE # AWS Access Key ID
secret_key: wJalrXUtnFEMI/K7MDENG... # AWS Secret Access Key
```
### Поля
| Поле | Тип | Обязательный | Описание |
|------|-----|--------------|----------|
| `storage.type` | string | да | Тип хранилища. Значение: `s3` |
| `storage.endpoint` | string | да | URL S3 endpoint |
| `storage.region` | string | нет | AWS region (default: `us-east-1`) |
| `storage.bucket` | string | да | Имя S3 bucket |
| `storage.access_key` | string | да | AWS Access Key ID |
| `storage.secret_key` | string | да | AWS Secret Access Key |
### Примеры для разных провайдеров
**AWS S3:**
```yaml
storage:
type: s3
endpoint: https://s3.amazonaws.com
region: eu-central-1
bucket: my-envs
access_key: AKIA...
secret_key: xxx...
```
**MinIO (self-hosted):**
```yaml
storage:
type: s3
endpoint: https://minio.myserver.com
bucket: envs
access_key: minioadmin
secret_key: minioadmin
```
**Cloudflare R2:**
```yaml
storage:
type: s3
endpoint: https://xxx.r2.cloudflarestorage.com
bucket: my-envs
access_key: xxx
secret_key: xxx
```
**DigitalOcean Spaces:**
```yaml
storage:
type: s3
endpoint: https://nyc3.digitaloceanspaces.com
region: nyc3
bucket: my-envs
access_key: xxx
secret_key: xxx
```
---
## Локальный конфиг (проектный)
**Путь:** `./aevs.yaml` (в корне проекта)
**Содержимое:**
```yaml
project: my-awesome-project # уникальное имя проекта
files: # список файлов для синхронизации
- .env.prod
- .env.dev
- .env.local
- docker/.env
- services/api/.env
```
### Поля
| Поле | Тип | Обязательный | Описание |
|------|-----|--------------|----------|
| `project` | string | да | Уникальное имя проекта (идентификатор в storage) |
| `files` | []string | да | Список относительных путей к файлам |
### Правила для `project`
- Должен быть уникальным в пределах bucket
- Допустимые символы: `a-z`, `0-9`, `-`, `_`
- Рекомендуется: kebab-case (`my-awesome-project`)
- Не должен содержать: пробелы, спецсимволы, `/`
### Правила для `files`
- Относительные пути от корня проекта
- Могут содержать поддиректории (`docker/.env`)
- При push проверяется существование каждого файла
- При pull создаются необходимые директории
---
## Приоритет конфигурации
```
1. Флаги командной строки (--config, --bucket, etc.)
2. Environment variables (AEVS_BUCKET, AEVS_ACCESS_KEY, etc.)
3. Глобальный конфиг (~/.config/aevs/config.yaml)
```
### Environment Variables
| Переменная | Описание |
|------------|----------|
| `AEVS_STORAGE_TYPE` | Тип хранилища |
| `AEVS_ENDPOINT` | S3 endpoint |
| `AEVS_REGION` | AWS region |
| `AEVS_BUCKET` | Имя bucket |
| `AEVS_ACCESS_KEY` | Access Key ID |
| `AEVS_SECRET_KEY` | Secret Access Key |
---
## Metadata файл
При каждом `push` создаётся `metadata.json` рядом с архивом.
**Путь в storage:** `{bucket}/{project}/metadata.json`
**Содержимое:**
```json
{
"updated_at": "2026-01-28T15:30:00Z",
"files": [
".env.prod",
".env.dev",
"docker/.env"
],
"machine": "MacBook-Pro.local",
"size_bytes": 1234
}
```
### Поля metadata
| Поле | Тип | Описание |
|------|-----|----------|
| `updated_at` | string (ISO 8601) | Время последнего push |
| `files` | []string | Список файлов в архиве |
| `machine` | string | Hostname машины, с которой сделан push |
| `size_bytes` | int | Размер архива в байтах |
Metadata используется для:
- Отображения информации в `aevs list`
- Проверки что именно будет скачано при `pull`

View File

@ -0,0 +1,482 @@
# Commands
## Обзор команд
| Команда | Описание |
|---------|----------|
| `aevs config` | Настройка глобального конфига (credentials) |
| `aevs init` | Инициализация проекта в текущей директории |
| `aevs push` | Загрузить .env файлы в storage |
| `aevs pull` | Скачать .env файлы из storage |
| `aevs list` | Показать список проектов в storage |
| `aevs status` | Показать статус текущего проекта |
---
## `aevs config`
Интерактивная настройка глобального конфига.
### Синтаксис
```bash
aevs config
```
### Поведение
1. Запрашивает параметры storage интерактивно
2. Проверяет подключение к storage (list bucket)
3. Сохраняет в `~/.config/aevs/config.yaml`
4. Устанавливает права `0600` на файл
### Пример сессии
```
$ aevs config
AEVS Configuration
==================
Storage type (s3): s3
S3 Endpoint (https://s3.amazonaws.com):
AWS Region (us-east-1): eu-central-1
Bucket name: my-envs-bucket
Access Key ID: AKIA...
Secret Access Key: ****
Testing connection...
✓ Successfully connected to s3://my-envs-bucket
Config saved to ~/.config/aevs/config.yaml
```
### Ошибки
| Ситуация | Сообщение |
|----------|-----------|
| Невалидные credentials | `Error: Access Denied. Check your credentials.` |
| Bucket не существует | `Error: Bucket "xxx" not found.` |
| Сетевая ошибка | `Error: Could not connect to endpoint.` |
---
## `aevs init`
Инициализация проекта в текущей директории.
### Синтаксис
```bash
aevs init [project-name]
```
### Аргументы
| Аргумент | Обязательный | Описание |
|----------|--------------|----------|
| `project-name` | нет | Имя проекта. По умолчанию: имя текущей директории |
### Флаги
| Флаг | Сокращение | Описание |
|------|------------|----------|
| `--force` | `-f` | Перезаписать существующий `aevs.yaml` |
### Поведение (новый проект)
1. Проверяет, что `aevs.yaml` не существует
2. Сканирует директорию на `.env*` файлы (см. [File Scanning](#file-scanning))
3. Если `project-name` не указан — запрашивает интерактивно (default: имя папки)
4. Создаёт `aevs.yaml` с найденными файлами
5. Выводит результат
### Поведение (проект существует)
1. Если `aevs.yaml` существует и нет `--force`:
- Сканирует на новые `.env*` файлы
- Предлагает добавить новые файлы в конфиг
2. Если `--force`:
- Перезаписывает конфиг полностью
### Пример сессии
```
$ aevs init my-project
Scanning for env files...
Found 3 env files:
✓ .env.prod
✓ .env.dev
✓ docker/.env
Created aevs.yaml for project "my-project"
Next steps:
1. Review aevs.yaml
2. Run 'aevs push' to upload files
```
### Пример (добавление новых файлов)
```
$ aevs init
Project "my-project" already initialized.
Scanning for new env files...
Found 1 new file:
+ services/.env.api
Add to aevs.yaml? [Y/n]: y
Updated aevs.yaml
```
### File Scanning
**Паттерны для включения:**
- `.env` — точное совпадение
- `.env.*` — например `.env.prod`, `.env.local`, `.env.development`
- `*.env` — например `docker.env`, `app.env`
**Игнорируемые директории:**
- `node_modules/`
- `.git/`
- `vendor/`
- `venv/`, `.venv/`
- `__pycache__/`
- `.idea/`, `.vscode/`
**Игнорируемые файлы:**
- `.env.example`
- `.env.sample`
- `.env.template`
### Ошибки
| Ситуация | Сообщение |
|----------|-----------|
| Config существует | `Error: aevs.yaml already exists. Use --force to overwrite.` |
| Невалидное имя проекта | `Error: Invalid project name. Use only a-z, 0-9, -, _` |
| Нет env файлов | `Warning: No env files found. Add files manually to aevs.yaml` |
---
## `aevs push`
Загружает локальные `.env` файлы в storage.
### Синтаксис
```bash
aevs push
```
### Флаги
| Флаг | Сокращение | Описание |
|------|------------|----------|
| `--config` | `-c` | Путь к конфигу (default: `./aevs.yaml`) |
| `--dry-run` | | Показать что будет загружено, без реальной загрузки |
### Поведение
1. Читает `aevs.yaml`
2. Проверяет существование всех файлов из `files`
3. Создаёт tar.gz архив в памяти
4. Загружает архив в `{bucket}/{project}/envs.tar.gz`
5. Загружает metadata в `{bucket}/{project}/metadata.json`
6. Выводит результат
### Структура архива
```
envs.tar.gz
├── .env.prod
├── .env.dev
└── docker/
└── .env
```
Пути в архиве сохраняются относительно корня проекта.
### Пример вывода
```
$ aevs push
Pushing "my-project" to storage...
✓ .env.prod (234 bytes)
✓ .env.dev (189 bytes)
✓ docker/.env (56 bytes)
Uploaded to s3://my-envs-bucket/my-project/envs.tar.gz
Total: 3 files, 479 bytes
```
### Пример (dry-run)
```
$ aevs push --dry-run
Dry run - no changes will be made
Would upload:
.env.prod (234 bytes)
.env.dev (189 bytes)
docker/.env (56 bytes)
Target: s3://my-envs-bucket/my-project/envs.tar.gz
```
### Ошибки
| Ситуация | Сообщение |
|----------|-----------|
| Нет aevs.yaml | `Error: No aevs.yaml found. Run 'aevs init' first.` |
| Нет глобального конфига | `Error: No storage configured. Run 'aevs config' first.` |
| Файл не найден | `Error: File not found: .env.prod` |
| Ошибка S3 | `Error: Storage error: {details}` |
---
## `aevs pull`
Скачивает `.env` файлы из storage.
### Синтаксис
```bash
aevs pull [project-name]
```
### Аргументы
| Аргумент | Обязательный | Описание |
|----------|--------------|----------|
| `project-name` | нет | Имя проекта для скачивания |
### Флаги
| Флаг | Сокращение | Описание |
|------|------------|----------|
| `--config` | `-c` | Путь к конфигу (default: `./aevs.yaml`) |
| `--force` | `-f` | Перезаписать существующие файлы без подтверждения |
| `--dry-run` | | Показать что будет скачано |
### Режимы работы
**Режим 1: С локальным конфигом**
```bash
aevs pull
```
- Читает `project` из `./aevs.yaml`
- Скачивает и распаковывает файлы
**Режим 2: По имени проекта**
```bash
aevs pull my-project
```
- Скачивает архив для указанного проекта
- Распаковывает в текущую директорию
- Не создаёт и не изменяет `aevs.yaml`
### Поведение
1. Определяет имя проекта (из аргумента или конфига)
2. Скачивает metadata.json для проверки
3. Скачивает envs.tar.gz
4. Для каждого файла в архиве:
- Если файл не существует — создаёт (и директории)
- Если файл существует и отличается — спрашивает что делать
- Если `--force` — перезаписывает без вопросов
5. Выводит результат
### Обработка конфликтов
```
$ aevs pull
Pulling "my-project"...
File .env.prod already exists and differs from remote.
Local: DATABASE_URL=postgres://localhost
Remote: DATABASE_URL=postgres://prod.db
[o]verwrite / [s]kip / [d]iff / [O]verwrite all / [S]kip all: _
```
| Опция | Действие |
|-------|----------|
| `o` | Перезаписать этот файл |
| `s` | Пропустить этот файл |
| `d` | Показать полный diff |
| `O` | Перезаписать все конфликтующие файлы |
| `S` | Пропустить все конфликтующие файлы |
### Пример вывода
```
$ aevs pull my-project
Pulling "my-project" from storage...
✓ .env.prod (created)
✓ .env.dev (created)
✓ docker/.env (created directory, created file)
Done. 3 files pulled.
```
### Пример (с конфликтами)
```
$ aevs pull --force
Pulling "my-project" from storage...
✓ .env.prod (overwritten)
- .env.dev (unchanged)
✓ docker/.env (overwritten)
Done. 2 files updated, 1 unchanged.
```
### Ошибки
| Ситуация | Сообщение |
|----------|-----------|
| Проект не указан и нет aevs.yaml | `Error: Project name required. Usage: aevs pull <project-name>` |
| Проект не найден в storage | `Error: Project "xxx" not found in storage.` |
| Нет глобального конфига | `Error: No storage configured. Run 'aevs config' first.` |
---
## `aevs list`
Показывает список проектов в storage.
### Синтаксис
```bash
aevs list
```
### Флаги
| Флаг | Сокращение | Описание |
|------|------------|----------|
| `--json` | | Вывод в формате JSON |
### Поведение
1. Читает глобальный конфиг
2. Запрашивает список "директорий" в bucket (S3 list с delimiter `/`)
3. Для каждого проекта читает metadata.json
4. Выводит отсортированный список
### Пример вывода
```
$ aevs list
Projects in s3://my-envs-bucket:
PROJECT FILES UPDATED SIZE
my-awesome-project 3 2 hours ago 479 B
another-project 5 3 days ago 1.2 KB
old-project 2 45 days ago 234 B
Total: 3 projects
```
### Пример (JSON)
```
$ aevs list --json
[
{
"project": "my-awesome-project",
"files": 3,
"updated_at": "2026-01-28T13:30:00Z",
"size_bytes": 479
},
...
]
```
### Ошибки
| Ситуация | Сообщение |
|----------|-----------|
| Нет глобального конфига | `Error: No storage configured. Run 'aevs config' first.` |
| Bucket пустой | `No projects found in storage.` |
---
## `aevs status`
Показывает статус текущего проекта.
### Синтаксис
```bash
aevs status
```
### Флаги
| Флаг | Сокращение | Описание |
|------|------------|----------|
| `--config` | `-c` | Путь к конфигу (default: `./aevs.yaml`) |
### Поведение
1. Читает локальный конфиг
2. Скачивает metadata.json из storage
3. Сравнивает локальные файлы с remote
4. Выводит статус
### Пример вывода
```
$ aevs status
Project: my-awesome-project
Storage: s3://my-envs-bucket/my-awesome-project
Remote last updated: 2 hours ago (from MacBook-Pro.local)
FILE LOCAL REMOTE STATUS
.env.prod 234 B 234 B ✓ up to date
.env.dev 195 B 189 B ⚡ local modified
docker/.env — 56 B ✗ missing locally
.env.staging 128 B — + local only
Summary: 1 up to date, 1 modified, 1 missing, 1 new
```
### Статусы файлов
| Статус | Описание |
|--------|----------|
| `✓ up to date` | Файлы идентичны |
| `⚡ local modified` | Локальный файл изменён |
| `⚡ remote modified` | Remote файл изменён (другая машина сделала push) |
| `✗ missing locally` | Файл есть в remote, но нет локально |
| `+ local only` | Файл есть локально, но нет в remote |
### Ошибки
| Ситуация | Сообщение |
|----------|-----------|
| Нет aevs.yaml | `Error: No aevs.yaml found. Run 'aevs init' first.` |
| Проект не найден в storage | `Project "xxx" not found in storage. Run 'aevs push' first.` |

View File

@ -0,0 +1,291 @@
# Types & Data Structures
## Go Types
### GlobalConfig
Глобальный конфиг, хранится в `~/.config/aevs/config.yaml`.
```go
package config
// GlobalConfig represents ~/.config/aevs/config.yaml
type GlobalConfig struct {
Storage StorageConfig `yaml:"storage"`
}
// StorageConfig holds S3-compatible storage credentials
type StorageConfig struct {
Type string `yaml:"type"` // storage type: "s3"
Endpoint string `yaml:"endpoint"` // S3 endpoint URL
Region string `yaml:"region"` // AWS region (optional, default: us-east-1)
Bucket string `yaml:"bucket"` // S3 bucket name
AccessKey string `yaml:"access_key"` // AWS Access Key ID
SecretKey string `yaml:"secret_key"` // AWS Secret Access Key
}
```
**Валидация:**
- `Type` — должен быть `"s3"` (в будущем: `"gcs"`, `"azure"`)
- `Endpoint` — валидный URL
- `Bucket` — непустая строка, валидное имя S3 bucket
- `AccessKey`, `SecretKey` — непустые строки
---
### ProjectConfig
Локальный конфиг проекта, хранится в `./aevs.yaml`.
```go
package config
// ProjectConfig represents ./aevs.yaml
type ProjectConfig struct {
Project string `yaml:"project"` // unique project identifier
Files []string `yaml:"files"` // list of file paths to sync
}
```
**Валидация:**
- `Project` — непустая строка, только `[a-z0-9_-]`
- `Files` — непустой массив, каждый элемент — валидный относительный путь
---
### Metadata
Метаданные проекта, хранятся в storage как `{project}/metadata.json`.
```go
package types
import "time"
// Metadata stored alongside the archive in S3
type Metadata struct {
UpdatedAt time.Time `json:"updated_at"` // last push timestamp
Files []string `json:"files"` // list of files in archive
Machine string `json:"machine"` // hostname of machine that pushed
SizeBytes int64 `json:"size_bytes"` // archive size in bytes
}
```
---
### ProjectInfo
Информация о проекте для отображения в `aevs list`.
```go
package types
import "time"
// ProjectInfo represents a project in storage (for listing)
type ProjectInfo struct {
Name string `json:"project"`
FileCount int `json:"files"`
UpdatedAt time.Time `json:"updated_at"`
SizeBytes int64 `json:"size_bytes"`
}
```
---
### FileStatus
Статус файла для `aevs status`.
```go
package types
// FileStatus represents the sync status of a single file
type FileStatus struct {
Path string `json:"path"`
LocalSize int64 `json:"local_size"` // -1 if missing
RemoteSize int64 `json:"remote_size"` // -1 if missing
Status SyncStatus `json:"status"`
LocalHash string `json:"local_hash"` // MD5 or SHA256
RemoteHash string `json:"remote_hash"`
}
// SyncStatus represents the synchronization state
type SyncStatus string
const (
StatusUpToDate SyncStatus = "up_to_date"
StatusLocalModified SyncStatus = "local_modified"
StatusRemoteModified SyncStatus = "remote_modified"
StatusMissingLocal SyncStatus = "missing_local"
StatusLocalOnly SyncStatus = "local_only"
StatusConflict SyncStatus = "conflict" // both modified
)
```
---
## Constants
```go
package config
const (
// DefaultConfigDir is the directory for global config
DefaultConfigDir = ".config/aevs"
// DefaultGlobalConfigFile is the global config filename
DefaultGlobalConfigFile = "config.yaml"
// DefaultProjectConfigFile is the project config filename
DefaultProjectConfigFile = "aevs.yaml"
// DefaultStorageType is the default storage backend
DefaultStorageType = "s3"
// DefaultRegion is the default AWS region
DefaultRegion = "us-east-1"
// DefaultEndpoint is the default S3 endpoint
DefaultEndpoint = "https://s3.amazonaws.com"
// ArchiveFileName is the name of the archive in storage
ArchiveFileName = "envs.tar.gz"
// MetadataFileName is the name of the metadata file in storage
MetadataFileName = "metadata.json"
)
```
---
## File Patterns
```go
package scanner
// IncludePatterns are glob patterns for env files
var IncludePatterns = []string{
".env",
".env.*",
"*.env",
}
// ExcludePatterns are glob patterns to exclude
var ExcludePatterns = []string{
".env.example",
".env.sample",
".env.template",
}
// ExcludeDirs are directories to skip when scanning
var ExcludeDirs = []string{
"node_modules",
".git",
"vendor",
"venv",
".venv",
"__pycache__",
".idea",
".vscode",
"dist",
"build",
}
```
---
## Interfaces
### Storage
```go
package storage
import (
"context"
"io"
"aevs/types"
)
// Storage interface for cloud storage operations
type Storage interface {
// Upload uploads data to the specified key
Upload(ctx context.Context, key string, data io.Reader, size int64) error
// Download downloads data from the specified key
Download(ctx context.Context, key string) (io.ReadCloser, error)
// Delete deletes the specified key
Delete(ctx context.Context, key string) error
// Exists checks if the key exists
Exists(ctx context.Context, key string) (bool, error)
// List lists all keys with the given prefix
List(ctx context.Context, prefix string) ([]string, error)
// ListProjects returns all project directories
ListProjects(ctx context.Context) ([]string, error)
}
```
### Scanner
```go
package scanner
// Scanner interface for finding env files
type Scanner interface {
// Scan scans the directory for env files
Scan(rootDir string) ([]string, error)
}
```
### Archiver
```go
package archiver
import "io"
// Archiver interface for creating/extracting archives
type Archiver interface {
// Create creates a tar.gz archive from the given files
Create(files []string, rootDir string) (io.Reader, int64, error)
// Extract extracts a tar.gz archive to the given directory
Extract(archive io.Reader, destDir string) ([]string, error)
// List lists files in the archive without extracting
List(archive io.Reader) ([]string, error)
}
```
---
## Error Types
```go
package errors
import "errors"
var (
// Config errors
ErrNoGlobalConfig = errors.New("no storage configured; run 'aevs config' first")
ErrNoProjectConfig = errors.New("no aevs.yaml found; run 'aevs init' first")
ErrConfigExists = errors.New("aevs.yaml already exists; use --force to overwrite")
ErrInvalidProject = errors.New("invalid project name; use only a-z, 0-9, -, _")
// Storage errors
ErrProjectNotFound = errors.New("project not found in storage")
ErrAccessDenied = errors.New("access denied; check your credentials")
ErrBucketNotFound = errors.New("bucket not found")
// File errors
ErrFileNotFound = errors.New("file not found")
ErrNoEnvFiles = errors.New("no env files found")
)
```

View File

@ -0,0 +1,382 @@
# Usage Scenarios
## Сценарий 1: Первоначальная настройка
**Контекст:** Первый запуск AEVS на новой машине.
```bash
# 1. Установка (предполагается, что бинарник уже собран)
$ go install github.com/user/aevs@latest
# 2. Настройка credentials для S3
$ aevs config
AEVS Configuration
==================
Storage type (s3): s3
S3 Endpoint (https://s3.amazonaws.com):
AWS Region (us-east-1): eu-central-1
Bucket name: my-envs-bucket
Access Key ID: AKIAIOSFODNN7EXAMPLE
Secret Access Key: ****
Testing connection...
✓ Successfully connected to s3://my-envs-bucket
Config saved to ~/.config/aevs/config.yaml
```
**Результат:** Глобальный конфиг создан, можно работать с любыми проектами.
---
## Сценарий 2: Новый проект на основной машине
**Контекст:** Начинаю новый проект на ноутбуке, хочу настроить синхронизацию env файлов.
```bash
# 1. Перейти в проект
$ cd ~/projects/my-app
# 2. Проверить что есть env файлы
$ ls -la
-rw-r--r-- .env.development
-rw-r--r-- .env.production
drwxr-xr-x docker/
└── .env
# 3. Инициализировать AEVS
$ aevs init my-app
Scanning for env files...
Found 3 env files:
✓ .env.development
✓ .env.production
✓ docker/.env
Created aevs.yaml for project "my-app"
# 4. Проверить созданный конфиг
$ cat aevs.yaml
project: my-app
files:
- .env.development
- .env.production
- docker/.env
# 5. Загрузить в storage
$ aevs push
Pushing "my-app" to storage...
✓ .env.development (156 bytes)
✓ .env.production (234 bytes)
✓ docker/.env (89 bytes)
Uploaded to s3://my-envs-bucket/my-app/envs.tar.gz
Total: 3 files, 479 bytes
```
**Результат:** Проект настроен, env файлы загружены в storage.
---
## Сценарий 3: Продолжение работы на другой машине
**Контекст:** Приехал в офис, нужно продолжить работу над проектом.
```bash
# 1. Склонировать репозиторий
$ git clone git@github.com:user/my-app.git
$ cd my-app
# 2. Посмотреть какие проекты есть в storage
$ aevs list
Projects in s3://my-envs-bucket:
PROJECT FILES UPDATED SIZE
my-app 3 2 hours ago 479 B
old-proj 2 30 days ago 234 B
# 3. Скачать env файлы
$ aevs pull my-app
Pulling "my-app" from storage...
✓ .env.development (created)
✓ .env.production (created)
✓ docker/.env (created directory, created file)
Done. 3 files pulled.
# 4. (Опционально) Инициализировать локальный конфиг для удобства
$ aevs init my-app
Project "my-app" found in storage.
Using existing configuration.
Created aevs.yaml for project "my-app"
# Теперь можно делать push/pull без указания имени проекта
$ aevs push
$ aevs pull
```
**Результат:** Рабочее окружение восстановлено, можно работать.
---
## Сценарий 4: Обновление env файлов
**Контекст:** Добавил новую переменную, нужно синхронизировать.
### На машине A (где изменил):
```bash
# 1. Изменить env файл
$ echo "NEW_API_KEY=secret123" >> .env.production
# 2. Проверить статус
$ aevs status
Project: my-app
Storage: s3://my-envs-bucket/my-app
FILE LOCAL REMOTE STATUS
.env.development 156 B 156 B ✓ up to date
.env.production 267 B 234 B ⚡ local modified
docker/.env 89 B 89 B ✓ up to date
Summary: 2 up to date, 1 modified
# 3. Загрузить изменения
$ aevs push
Pushing "my-app" to storage...
✓ .env.development (156 bytes)
✓ .env.production (267 bytes)
✓ docker/.env (89 bytes)
Uploaded to s3://my-envs-bucket/my-app/envs.tar.gz
```
### На машине B (получить изменения):
```bash
# 1. Проверить статус
$ aevs status
FILE LOCAL REMOTE STATUS
.env.development 156 B 156 B ✓ up to date
.env.production 234 B 267 B ⚡ remote modified
docker/.env 89 B 89 B ✓ up to date
# 2. Получить изменения
$ aevs pull
Pulling "my-app" from storage...
- .env.development (unchanged)
✓ .env.production (updated)
- docker/.env (unchanged)
Done. 1 file updated, 2 unchanged.
```
---
## Сценарий 5: Добавление нового env файла
**Контекст:** Добавил новый сервис с отдельным env файлом.
```bash
# 1. Создать новый env файл
$ mkdir -p services/auth
$ echo "AUTH_SECRET=xxx" > services/auth/.env
# 2. Запустить init для обнаружения новых файлов
$ aevs init
Project "my-app" already initialized.
Scanning for new env files...
Found 1 new file:
+ services/auth/.env
Add to aevs.yaml? [Y/n]: y
Updated aevs.yaml
# 3. Проверить конфиг
$ cat aevs.yaml
project: my-app
files:
- .env.development
- .env.production
- docker/.env
- services/auth/.env
# 4. Загрузить
$ aevs push
```
---
## Сценарий 6: Конфликт при pull
**Контекст:** Изменил файл локально, но на другой машине тоже были изменения.
```bash
$ aevs pull
Pulling "my-app" from storage...
✓ .env.development (unchanged)
File .env.production already exists and differs from remote.
Local version:
DATABASE_URL=postgres://localhost:5432/dev
API_KEY=local-key
Remote version:
DATABASE_URL=postgres://prod.server:5432/prod
API_KEY=remote-key
[o]verwrite / [s]kip / [d]iff / [O]verwrite all: o
✓ .env.production (overwritten)
✓ docker/.env (unchanged)
Done. 1 file updated.
```
---
## Сценарий 7: Dry-run перед push
**Контекст:** Хочу проверить что будет загружено.
```bash
$ aevs push --dry-run
Dry run - no changes will be made
Would upload:
.env.development (156 bytes)
.env.production (267 bytes)
docker/.env (89 bytes)
services/auth/.env (45 bytes)
Target: s3://my-envs-bucket/my-app/envs.tar.gz
Total: 4 files, 557 bytes
```
---
## Сценарий 8: Удаление проекта из storage
**Контекст:** Проект больше не нужен, хочу очистить storage.
```bash
# Пока нет команды delete, можно использовать aws cli
$ aws s3 rm s3://my-envs-bucket/old-project/ --recursive
delete: s3://my-envs-bucket/old-project/envs.tar.gz
delete: s3://my-envs-bucket/old-project/metadata.json
# Проверить
$ aevs list
Projects in s3://my-envs-bucket:
PROJECT FILES UPDATED SIZE
my-app 4 5 min ago 557 B
Total: 1 project
```
---
## Сценарий 9: Использование с разными S3 провайдерами
### MinIO (self-hosted)
```bash
$ aevs config
Storage type (s3): s3
S3 Endpoint: https://minio.myserver.com:9000
AWS Region:
Bucket name: envs
Access Key ID: minioadmin
Secret Access Key: ****
Testing connection...
✓ Successfully connected
```
### Cloudflare R2
```bash
$ aevs config
Storage type (s3): s3
S3 Endpoint: https://abc123.r2.cloudflarestorage.com
AWS Region: auto
Bucket name: my-envs
Access Key ID: xxx
Secret Access Key: ****
```
---
## Сценарий 10: Восстановление предыдущей версии
**Контекст:** Случайно запушил неправильные env, нужно откатиться.
```bash
# AEVS не хранит версии, но S3 versioning может помочь
# 1. Посмотреть версии в S3
$ aws s3api list-object-versions \
--bucket my-envs-bucket \
--prefix my-app/envs.tar.gz
{
"Versions": [
{
"VersionId": "abc123",
"LastModified": "2026-01-28T15:00:00Z",
"Size": 557
},
{
"VersionId": "xyz789",
"LastModified": "2026-01-28T10:00:00Z",
"Size": 479
}
]
}
# 2. Скачать предыдущую версию
$ aws s3api get-object \
--bucket my-envs-bucket \
--key my-app/envs.tar.gz \
--version-id xyz789 \
envs-old.tar.gz
# 3. Распаковать и восстановить
$ tar -xzf envs-old.tar.gz
# 4. Или сделать эту версию текущей
$ aws s3 cp \
s3://my-envs-bucket/my-app/envs.tar.gz \
s3://my-envs-bucket/my-app/envs.tar.gz \
--source-version-id xyz789
```
**Примечание:** Для автоматизации можно добавить команду `aevs restore --version <id>` в будущем.

View File

@ -0,0 +1,273 @@
# Error Handling
## Категории ошибок
### 1. Configuration Errors
Ошибки связанные с отсутствием или невалидностью конфигурации.
| Код | Сообщение | Когда возникает | Решение |
|-----|-----------|-----------------|---------|
| `ERR_NO_GLOBAL_CONFIG` | `No storage configured. Run 'aevs config' first.` | Любая команда кроме `config` без глобального конфига | `aevs config` |
| `ERR_NO_PROJECT_CONFIG` | `No aevs.yaml found. Run 'aevs init' first.` | `push`, `status` без локального конфига | `aevs init` |
| `ERR_CONFIG_EXISTS` | `aevs.yaml already exists. Use --force to overwrite.` | `init` когда конфиг существует | `aevs init --force` |
| `ERR_INVALID_CONFIG` | `Invalid config: {details}` | Конфиг существует, но невалидный | Проверить синтаксис YAML |
### 2. Validation Errors
Ошибки валидации входных данных.
| Код | Сообщение | Когда возникает |
|-----|-----------|-----------------|
| `ERR_INVALID_PROJECT_NAME` | `Invalid project name "{name}". Use only a-z, 0-9, -, _` | Невалидное имя проекта |
| `ERR_EMPTY_FILES` | `No files specified in aevs.yaml` | Пустой список files |
| `ERR_INVALID_PATH` | `Invalid file path: {path}` | Путь содержит `..` или абсолютный |
### 3. File System Errors
Ошибки работы с локальной файловой системой.
| Код | Сообщение | Когда возникает |
|-----|-----------|-----------------|
| `ERR_FILE_NOT_FOUND` | `File not found: {path}` | Файл из config не существует при push |
| `ERR_PERMISSION_DENIED` | `Permission denied: {path}` | Нет прав на чтение/запись |
| `ERR_DIR_CREATE_FAILED` | `Failed to create directory: {path}` | Не удалось создать директорию при pull |
### 4. Storage Errors
Ошибки работы с S3 storage.
| Код | Сообщение | Когда возникает |
|-----|-----------|-----------------|
| `ERR_ACCESS_DENIED` | `Access denied. Check your credentials.` | Невалидные credentials |
| `ERR_BUCKET_NOT_FOUND` | `Bucket "{name}" not found.` | Bucket не существует |
| `ERR_PROJECT_NOT_FOUND` | `Project "{name}" not found in storage.` | Проект не найден при pull |
| `ERR_UPLOAD_FAILED` | `Failed to upload: {details}` | Ошибка при загрузке |
| `ERR_DOWNLOAD_FAILED` | `Failed to download: {details}` | Ошибка при скачивании |
| `ERR_CONNECTION_FAILED` | `Could not connect to storage: {details}` | Сетевая ошибка |
### 5. Archive Errors
Ошибки работы с архивами.
| Код | Сообщение | Когда возникает |
|-----|-----------|-----------------|
| `ERR_ARCHIVE_CREATE` | `Failed to create archive: {details}` | Ошибка создания tar.gz |
| `ERR_ARCHIVE_EXTRACT` | `Failed to extract archive: {details}` | Ошибка распаковки |
| `ERR_ARCHIVE_CORRUPT` | `Archive is corrupted` | Повреждённый архив |
---
## Формат вывода ошибок
### Стандартный формат
```
Error: {сообщение}
{подсказка как исправить, если есть}
```
### Примеры
```
$ aevs push
Error: No aevs.yaml found. Run 'aevs init' first.
```
```
$ aevs pull my-project
Error: Project "my-project" not found in storage.
Available projects:
- my-app
- another-project
```
```
$ aevs push
Error: File not found: .env.production
The file is listed in aevs.yaml but doesn't exist.
Remove it from the config or create the file.
```
---
## Exit Codes
| Code | Meaning |
|------|---------|
| `0` | Success |
| `1` | General error |
| `2` | Configuration error |
| `3` | File system error |
| `4` | Storage error |
| `5` | Network error |
| `130` | Interrupted (Ctrl+C) |
### Использование в скриптах
```bash
#!/bin/bash
aevs push
exit_code=$?
case $exit_code in
0)
echo "Success"
;;
2)
echo "Config error - run 'aevs config' or 'aevs init'"
;;
4)
echo "Storage error - check credentials"
;;
5)
echo "Network error - check connection"
;;
*)
echo "Unknown error: $exit_code"
;;
esac
```
---
## Логирование
### Уровни логирования
По умолчанию выводятся только ошибки. Для подробного вывода:
```bash
# Verbose mode
$ aevs push -v
$ aevs push --verbose
# Debug mode (максимум деталей)
$ aevs push --debug
```
### Формат verbose вывода
```
$ aevs push -v
[INFO] Reading config from ./aevs.yaml
[INFO] Project: my-app
[INFO] Files to upload: 3
[INFO] Creating archive...
[INFO] Archive size: 479 bytes
[INFO] Uploading to s3://my-bucket/my-app/envs.tar.gz
[INFO] Uploading metadata...
[INFO] Done
Pushed 3 files to "my-app"
```
### Формат debug вывода
```
$ aevs push --debug
[DEBUG] Config path: /Users/me/project/aevs.yaml
[DEBUG] Global config: /Users/me/.config/aevs/config.yaml
[DEBUG] Storage endpoint: https://s3.amazonaws.com
[DEBUG] Bucket: my-bucket
[DEBUG] Reading file: .env.development (156 bytes)
[DEBUG] Reading file: .env.production (234 bytes)
[DEBUG] Reading file: docker/.env (89 bytes)
[DEBUG] Creating tar.gz archive
[DEBUG] S3 PutObject: my-app/envs.tar.gz
[DEBUG] S3 Response: 200 OK, ETag: "abc123"
[DEBUG] S3 PutObject: my-app/metadata.json
[DEBUG] S3 Response: 200 OK
...
```
---
## Graceful Degradation
### При сетевых ошибках
```
$ aevs push
Error: Could not connect to storage.
Retrying in 2 seconds... (1/3)
Retrying in 4 seconds... (2/3)
Retrying in 8 seconds... (3/3)
Error: Connection failed after 3 attempts.
Check your internet connection and try again.
```
### При прерывании (Ctrl+C)
```
$ aevs push
Pushing "my-app"...
✓ .env.development
^C
Interrupted. No changes were made to storage.
```
```
$ aevs pull
Pulling "my-app"...
✓ .env.development (created)
^C
Interrupted. Partially pulled files:
- .env.development
Run 'aevs pull' again to complete.
```
---
## Recovery Scenarios
### Corrupted local config
```
$ aevs push
Error: Invalid config: yaml: line 3: did not find expected key
Fix the syntax error in aevs.yaml or run 'aevs init --force' to recreate.
```
### Missing credentials
```
$ aevs push
Error: Access denied. Check your credentials.
Your credentials may have expired or been revoked.
Run 'aevs config' to update them.
```
### Partial push failure
```
$ aevs push
Pushing "my-app"...
✓ .env.development
✓ .env.production
✗ docker/.env (failed)
Error: Upload interrupted.
Some files may have been uploaded. Run 'aevs push' again to retry.
```
При повторном push всё будет загружено заново (идемпотентность).

View File

@ -0,0 +1,62 @@
# AEVS CLI — Simplified Version
> CLI-инструмент для синхронизации `.env` файлов между машинами одного пользователя.
## Документация
| Файл | Описание |
|------|----------|
| [01-overview.md](./01-overview.md) | Обзор проекта, архитектура, scope |
| [02-configuration.md](./02-configuration.md) | Конфигурационные файлы (глобальный и локальный) |
| [03-commands.md](./03-commands.md) | Детальное описание всех команд CLI |
| [04-types.md](./04-types.md) | Go типы, интерфейсы, константы |
| [05-scenarios.md](./05-scenarios.md) | Сценарии использования с примерами |
| [06-errors.md](./06-errors.md) | Обработка ошибок, exit codes, логирование |
## Quick Start
```bash
# 1. Настроить credentials (один раз на машине)
aevs config
# 2. Инициализировать проект
cd my-project
aevs init
# 3. Загрузить env файлы в storage
aevs push
# 4. На другой машине — скачать
aevs pull my-project
```
## Команды
| Команда | Описание |
|---------|----------|
| `aevs config` | Настройка credentials для S3 |
| `aevs init [name]` | Инициализация проекта |
| `aevs push` | Загрузить файлы в storage |
| `aevs pull [name]` | Скачать файлы из storage |
| `aevs list` | Список проектов в storage |
| `aevs status` | Статус синхронизации |
## Ключевые отличия от v1 (01-opus-docs)
| Аспект | v1 (сложная) | v2 (упрощённая) |
|--------|--------------|-----------------|
| Целевая аудитория | Команда | Один пользователь |
| Backend | REST API сервер | S3 storage |
| Версионирование | Встроенное (timestamps) | S3 versioning |
| Merge | Интерактивный | Не нужен |
| Конфликты | Разрешение конфликтов | Последний push побеждает |
| Команды | 6+ с множеством флагов | 6 простых |
| Код | ~1000+ строк | ~300-500 строк |
| Время разработки | 2-3 дня | 4-6 часов |
## Tech Stack
- **Language:** Go
- **CLI Framework:** cobra
- **Storage:** AWS S3 (или совместимые: MinIO, R2, Spaces)
- **Config:** YAML (gopkg.in/yaml.v3)

View File

@ -0,0 +1,730 @@
# AEVS CLI — Implementation Plan
> Пошаговый план для агента-разработчика.
## Общая информация
**Проект:** AEVS CLI — инструмент синхронизации `.env` файлов между машинами одного пользователя.
**Технологии:**
- Go (golang)
- CLI: `github.com/spf13/cobra`
- S3: `github.com/aws/aws-sdk-go-v2`
- YAML: `gopkg.in/yaml.v3`
**Документация:** `.docs/02-opus-cli-docs/`
---
## Фаза 1: Инициализация проекта
### Шаг 1.1: Создание структуры проекта
Создай следующую структуру директорий:
```
aevs/
├── cmd/
│ └── aevs/
│ └── main.go # точка входа
├── internal/
│ ├── cli/
│ │ ├── root.go # root command
│ │ ├── config.go # aevs config
│ │ ├── init.go # aevs init
│ │ ├── push.go # aevs push
│ │ ├── pull.go # aevs pull
│ │ ├── list.go # aevs list
│ │ └── status.go # aevs status
│ ├── config/
│ │ ├── global.go # GlobalConfig, загрузка/сохранение
│ │ ├── project.go # ProjectConfig
│ │ └── constants.go # константы
│ ├── storage/
│ │ ├── storage.go # Storage interface
│ │ └── s3.go # S3 implementation
│ ├── archiver/
│ │ └── archiver.go # tar.gz создание/распаковка
│ ├── scanner/
│ │ └── scanner.go # сканирование .env файлов
│ └── types/
│ └── types.go # Metadata, FileStatus, etc.
├── go.mod
├── go.sum
└── README.md
```
**Чеклист:**
- [x] Создана директория `cmd/aevs/`
- [x] Создана директория `internal/cli/`
- [x] Создана директория `internal/config/`
- [x] Создана директория `internal/storage/`
- [x] Создана директория `internal/archiver/`
- [x] Создана директория `internal/scanner/`
- [x] Создана директория `internal/types/`
- [x] Проверен другой моделью и составлен следующий шаг
### Шаг 1.2: Инициализация Go модуля
```bash
go mod init github.com/user/aevs
```
**Чеклист:**
- [x] Выполнена команда `go mod init`
- [x] Создан файл `go.mod`
- [x] Проверен другой моделью и составлен следующий шаг
### Шаг 1.3: Добавление зависимостей
```bash
go get github.com/spf13/cobra@latest
go get github.com/aws/aws-sdk-go-v2/config@latest
go get github.com/aws/aws-sdk-go-v2/service/s3@latest
go get github.com/aws/aws-sdk-go-v2/credentials@latest
go get gopkg.in/yaml.v3@latest
```
**Чеклист:**
- [x] Добавлена зависимость `github.com/spf13/cobra`
- [x] Добавлена зависимость `github.com/aws/aws-sdk-go-v2/config`
- [x] Добавлена зависимость `github.com/aws/aws-sdk-go-v2/service/s3`
- [x] Добавлена зависимость `github.com/aws/aws-sdk-go-v2/credentials`
- [x] Добавлена зависимость `gopkg.in/yaml.v3`
- [x] Создан файл `go.sum`
- [x] Проверен другой моделью и составлен следующий шаг
---
## Фаза 2: Types & Config
### Шаг 2.1: Создай `internal/types/types.go`
Реализуй типы из документации (см. `.docs/02-opus-cli-docs/04-types.md`):
- `Metadata` — метаданные в storage
- `ProjectInfo` — для list
- `FileStatus` — для status
- `SyncStatus` — константы статусов
**Чеклист:**
- [x] Создан файл `internal/types/types.go`
- [x] Реализован тип `Metadata`
- [x] Реализован тип `ProjectInfo`
- [x] Реализован тип `FileStatus`
- [x] Реализован тип `SyncStatus` с константами
- [x] Код компилируется без ошибок
- [x] Проверен другой моделью и составлен следующий шаг
### Шаг 2.2: Создай `internal/config/constants.go`
Константы из документации:
- `DefaultConfigDir = ".config/aevs"`
- `DefaultGlobalConfigFile = "config.yaml"`
- `DefaultProjectConfigFile = "aevs.yaml"`
- `ArchiveFileName = "envs.tar.gz"`
- `MetadataFileName = "metadata.json"`
- и т.д.
**Чеклист:**
- [x] Создан файл `internal/config/constants.go`
- [x] Определена константа `DefaultConfigDir`
- [x] Определена константа `DefaultGlobalConfigFile`
- [x] Определена константа `DefaultProjectConfigFile`
- [x] Определена константа `ArchiveFileName`
- [x] Определена константа `MetadataFileName`
- [x] Определены дефолтные значения для S3
- [x] Проверен другой моделью и составлен следующий шаг
### Шаг 2.3: Создай `internal/config/global.go`
```go
type GlobalConfig struct {
Storage StorageConfig `yaml:"storage"`
}
type StorageConfig struct {
Type string `yaml:"type"`
Endpoint string `yaml:"endpoint"`
Region string `yaml:"region"`
Bucket string `yaml:"bucket"`
AccessKey string `yaml:"access_key"`
SecretKey string `yaml:"secret_key"`
}
```
Функции:
- `LoadGlobalConfig() (*GlobalConfig, error)` — загрузка из `~/.config/aevs/config.yaml`
- `SaveGlobalConfig(cfg *GlobalConfig) error` — сохранение с правами `0600`
- `GlobalConfigPath() string` — путь к конфигу
- `GlobalConfigExists() bool` — проверка существования
**Чеклист:**
- [x] Создан файл `internal/config/global.go`
- [x] Реализован тип `GlobalConfig`
- [x] Реализован тип `StorageConfig`
- [x] Реализована функция `LoadGlobalConfig()`
- [x] Реализована функция `SaveGlobalConfig()` с правами `0600`
- [x] Реализована функция `GlobalConfigPath()`
- [x] Реализована функция `GlobalConfigExists()`
- [x] Код компилируется без ошибок
- [x] Проверен другой моделью и составлен следующий шаг
### Шаг 2.4: Создай `internal/config/project.go`
```go
type ProjectConfig struct {
Project string `yaml:"project"`
Files []string `yaml:"files"`
}
```
Функции:
- `LoadProjectConfig(path string) (*ProjectConfig, error)`
- `SaveProjectConfig(path string, cfg *ProjectConfig) error`
- `ValidateProjectName(name string) error` — проверка `[a-z0-9_-]`
**Чеклист:**
- [x] Создан файл `internal/config/project.go`
- [x] Реализован тип `ProjectConfig`
- [x] Реализована функция `LoadProjectConfig()`
- [x] Реализована функция `SaveProjectConfig()`
- [x] Реализована функция `ValidateProjectName()` с regex `[a-z0-9_-]`
- [x] Код компилируется без ошибок
- [x] Проверен другой моделью и составлен следующий шаг
---
## Фаза 3: Storage Layer
### Шаг 3.1: Создай `internal/storage/storage.go`
Интерфейс Storage:
```go
type Storage interface {
Upload(ctx context.Context, key string, data io.Reader, size int64) error
Download(ctx context.Context, key string) (io.ReadCloser, error)
Delete(ctx context.Context, key string) error
Exists(ctx context.Context, key string) (bool, error)
List(ctx context.Context, prefix string) ([]string, error)
ListProjects(ctx context.Context) ([]string, error)
TestConnection(ctx context.Context) error
}
```
**Чеклист:**
- [x] Создан файл `internal/storage/storage.go`
- [x] Определён интерфейс `Storage`
- [x] Метод `Upload` в интерфейсе
- [x] Метод `Download` в интерфейсе
- [x] Метод `Delete` в интерфейсе
- [x] Метод `Exists` в интерфейсе
- [x] Метод `List` в интерфейсе
- [x] Метод `ListProjects` в интерфейсе
- [x] Метод `TestConnection` в интерфейсе
- [x] Проверен другой моделью и составлен следующий шаг
### Шаг 3.2: Создай `internal/storage/s3.go`
S3 реализация интерфейса Storage:
```go
type S3Storage struct {
client *s3.Client
bucket string
}
func NewS3Storage(cfg *config.StorageConfig) (*S3Storage, error)
```
Методы:
- `Upload``s3.PutObject`
- `Download``s3.GetObject`
- `Delete``s3.DeleteObject`
- `Exists``s3.HeadObject`
- `List``s3.ListObjectsV2`
- `ListProjects` — list с delimiter `/`
- `TestConnection``s3.ListBuckets` или `HeadBucket`
**Важно:** Используй `aws.Config` с кастомным endpoint для поддержки MinIO, R2, Spaces.
**Чеклист:**
- [x] Создан файл `internal/storage/s3.go`
- [x] Реализована структура `S3Storage`
- [x] Реализована функция `NewS3Storage()` с кастомным endpoint
- [x] Реализован метод `Upload()`
- [x] Реализован метод `Download()`
- [x] Реализован метод `Delete()`
- [x] Реализован метод `Exists()`
- [x] Реализован метод `List()`
- [x] Реализован метод `ListProjects()` с delimiter
- [x] Реализован метод `TestConnection()`
- [x] S3Storage реализует интерфейс Storage
- [x] Код компилируется без ошибок
- [x] Проверен другой моделью и составлен следующий шаг
---
## Фаза 4: Scanner & Archiver
### Шаг 4.1: Создай `internal/scanner/scanner.go`
Сканирование директории на `.env` файлы:
```go
func Scan(rootDir string) ([]string, error)
```
Паттерны для включения:
- `.env`
- `.env.*`
- `*.env`
Исключения (директории):
- `node_modules`, `.git`, `vendor`, `venv`, `.venv`, `__pycache__`, `.idea`, `.vscode`, `dist`, `build`
Исключения (файлы):
- `.env.example`, `.env.sample`, `.env.template`
**Чеклист:**
- [x] Создан файл `internal/scanner/scanner.go`
- [x] Определены паттерны включения
- [x] Определены исключаемые директории
- [x] Определены исключаемые файлы
- [x] Реализована функция `Scan()`
- [x] Функция рекурсивно обходит директории
- [x] Функция корректно фильтрует файлы
- [x] Код компилируется без ошибок
- [x] Проверен другой моделью и составлен следующий шаг
### Шаг 4.2: Создай `internal/archiver/archiver.go`
```go
func Create(files []string, rootDir string) (io.Reader, int64, error)
func Extract(archive io.Reader, destDir string) ([]string, error)
func List(archive io.Reader) ([]string, error)
```
- `Create` — создаёт tar.gz архив из списка файлов
- `Extract` — распаковывает архив в директорию, создаёт недостающие папки
- `List` — возвращает список файлов в архиве без распаковки
**Чеклист:**
- [x] Создан файл `internal/archiver/archiver.go`
- [x] Реализована функция `Create()` (tar.gz)
- [x] Реализована функция `Extract()` с созданием директорий
- [x] Реализована функция `List()`
- [x] Пути в архиве сохраняются относительно rootDir
- [x] Код компилируется без ошибок
- [x] Проверен другой моделью и составлен следующий шаг
---
## Фаза 5: CLI Commands
### Шаг 5.1: Создай `internal/cli/root.go`
Root command с cobra:
```go
var rootCmd = &cobra.Command{
Use: "aevs",
Short: "Sync .env files between machines",
}
func Execute() error {
return rootCmd.Execute()
}
```
Глобальные флаги:
- `--verbose`, `-v` — verbose output
- `--debug` — debug output
**Чеклист:**
- [x] Создан файл `internal/cli/root.go`
- [x] Определён `rootCmd` с cobra
- [x] Реализована функция `Execute()`
- [x] Добавлен флаг `--verbose` / `-v`
- [x] Добавлен флаг `--debug`
- [x] Код компилируется без ошибок
- [x] Проверен другой моделью и составлен следующий шаг
### Шаг 5.2: Создай `internal/cli/config.go` — команда `aevs config`
Интерактивная настройка credentials:
1. Запросить storage type (default: `s3`)
2. Запросить endpoint (default: `https://s3.amazonaws.com`)
3. Запросить region (default: `us-east-1`)
4. Запросить bucket name
5. Запросить access key
6. Запросить secret key (скрытый ввод)
7. Проверить подключение (`TestConnection`)
8. Сохранить конфиг
**Чеклист:**
- [x] Создан файл `internal/cli/config.go`
- [x] Определён `configCmd` с cobra
- [x] Реализован интерактивный ввод storage type
- [x] Реализован интерактивный ввод endpoint
- [x] Реализован интерактивный ввод region
- [x] Реализован интерактивный ввод bucket name
- [x] Реализован интерактивный ввод access key
- [x] Реализован скрытый ввод secret key
- [x] Вызывается `TestConnection()` для проверки
- [x] Конфиг сохраняется через `SaveGlobalConfig()`
- [x] Команда зарегистрирована в `rootCmd`
- [x] Код компилируется без ошибок
- [x] Проверен другой моделью и составлен следующий шаг
### Шаг 5.3: Создай `internal/cli/init.go` — команда `aevs init`
```bash
aevs init [project-name]
```
Флаги:
- `--force`, `-f` — перезаписать существующий конфиг
Логика:
1. Проверить существование `aevs.yaml`
2. Если существует и нет `--force`:
- Сканировать на новые файлы
- Предложить добавить
3. Если не существует:
- Сканировать директорию
- Определить имя проекта (аргумент или имя папки)
- Создать `aevs.yaml`
**Чеклист:**
- [x] Создан файл `internal/cli/init.go`
- [x] Определён `initCmd` с cobra
- [x] Добавлен флаг `--force` / `-f`
- [x] Реализована проверка существования `aevs.yaml`
- [x] Реализовано сканирование директории через `scanner.Scan()`
- [x] Реализовано определение имени проекта (аргумент или имя папки)
- [x] Реализовано добавление новых файлов в существующий конфиг
- [x] Создаётся `aevs.yaml` через `SaveProjectConfig()`
- [x] Команда зарегистрирована в `rootCmd`
- [x] Код компилируется без ошибок
- [x] Проверен другой моделью и составлен следующий шаг
### Шаг 5.4: Создай `internal/cli/push.go` — команда `aevs push`
Флаги:
- `--config`, `-c` — путь к конфигу
- `--dry-run` — показать что будет загружено
Логика:
1. Загрузить глобальный конфиг
2. Загрузить проектный конфиг
3. Проверить существование всех файлов
4. Создать tar.gz архив
5. Загрузить архив в `{project}/envs.tar.gz`
6. Создать и загрузить metadata.json
7. Вывести результат
**Чеклист:**
- [x] Создан файл `internal/cli/push.go`
- [x] Определён `pushCmd` с cobra
- [x] Добавлен флаг `--config` / `-c`
- [x] Добавлен флаг `--dry-run`
- [x] Реализована загрузка глобального конфига
- [x] Реализована загрузка проектного конфига
- [x] Реализована проверка существования файлов
- [x] Реализовано создание архива через `archiver.Create()`
- [x] Реализована загрузка архива через `storage.Upload()`
- [x] Реализовано создание и загрузка `metadata.json`
- [x] Реализован режим `--dry-run`
- [x] Команда зарегистрирована в `rootCmd`
- [x] Код компилируется без ошибок
- [x] Проверен другой моделью и составлен следующий шаг
### Шаг 5.5: Создай `internal/cli/pull.go` — команда `aevs pull`
```bash
aevs pull [project-name]
```
Флаги:
- `--config`, `-c`
- `--force`, `-f` — перезаписать без подтверждения
- `--dry-run`
Логика:
1. Определить имя проекта (аргумент или из `aevs.yaml`)
2. Скачать metadata.json
3. Скачать envs.tar.gz
4. Для каждого файла:
- Если не существует — создать
- Если существует и отличается — спросить (или --force)
5. Вывести результат
Обработка конфликтов:
- `[o]verwrite` — перезаписать
- `[s]kip` — пропустить
- `[d]iff` — показать diff
- `[O]verwrite all`
- `[S]kip all`
**Чеклист:**
- [x] Создан файл `internal/cli/pull.go`
- [x] Определён `pullCmd` с cobra
- [x] Добавлен флаг `--config` / `-c`
- [x] Добавлен флаг `--force` / `-f`
- [x] Добавлен флаг `--dry-run`
- [x] Реализовано определение имени проекта
- [x] Реализовано скачивание metadata.json
- [x] Реализовано скачивание envs.tar.gz
- [x] Реализована распаковка через `archiver.Extract()`
- [x] Реализована обработка конфликтов (o/s/d/O/S)
- [x] Реализован режим `--force`
- [x] Реализован режим `--dry-run`
- [x] Команда зарегистрирована в `rootCmd`
- [x] Код компилируется без ошибок
- [x] Проверен другой моделью и составлен следующий шаг
### Шаг 5.6: Создай `internal/cli/list.go` — команда `aevs list`
Флаги:
- `--json` — вывод в JSON
Логика:
1. Загрузить глобальный конфиг
2. Получить список проектов (`ListProjects`)
3. Для каждого проекта загрузить metadata.json
4. Вывести таблицу или JSON
**Чеклист:**
- [x] Создан файл `internal/cli/list.go`
- [x] Определён `listCmd` с cobra
- [x] Добавлен флаг `--json`
- [x] Реализована загрузка глобального конфига
- [x] Реализовано получение списка проектов через `ListProjects()`
- [x] Реализована загрузка metadata.json для каждого проекта
- [x] Реализован вывод в виде таблицы
- [x] Реализован вывод в формате JSON
- [x] Команда зарегистрирована в `rootCmd`
- [x] Код компилируется без ошибок
- [x] Проверен другой моделью и составлен следующий шаг
### Шаг 5.7: Создай `internal/cli/status.go` — команда `aevs status`
Флаги:
- `--config`, `-c`
Логика:
1. Загрузить оба конфига
2. Скачать metadata.json из storage
3. Для каждого файла:
- Сравнить размер и хеш локального vs remote
- Определить статус
4. Вывести таблицу статусов
**Чеклист:**
- [x] Создан файл `internal/cli/status.go`
- [x] Определён `statusCmd` с cobra
- [x] Добавлен флаг `--config` / `-c`
- [x] Реализована загрузка обоих конфигов
- [x] Реализовано скачивание metadata.json
- [x] Реализовано сравнение локальных и remote файлов
- [x] Реализовано определение статуса каждого файла
- [x] Реализован вывод таблицы статусов
- [x] Команда зарегистрирована в `rootCmd`
- [x] Код компилируется без ошибок
- [x] Проверен другой моделью и составлен следующий шаг
---
## Фаза 6: Error Handling
### Шаг 6.1: Создай `internal/errors/errors.go`
Определённые ошибки из документации:
```go
var (
ErrNoGlobalConfig = errors.New("no storage configured; run 'aevs config' first")
ErrNoProjectConfig = errors.New("no aevs.yaml found; run 'aevs init' first")
ErrConfigExists = errors.New("aevs.yaml already exists; use --force to overwrite")
ErrInvalidProject = errors.New("invalid project name; use only a-z, 0-9, -, _")
ErrProjectNotFound = errors.New("project not found in storage")
ErrAccessDenied = errors.New("access denied; check your credentials")
ErrBucketNotFound = errors.New("bucket not found")
ErrFileNotFound = errors.New("file not found")
ErrNoEnvFiles = errors.New("no env files found")
)
```
**Чеклист:**
- [x] Создан файл `internal/errors/errors.go`
- [x] Определена ошибка `ErrNoGlobalConfig`
- [x] Определена ошибка `ErrNoProjectConfig`
- [x] Определена ошибка `ErrConfigExists`
- [x] Определена ошибка `ErrInvalidProject`
- [x] Определена ошибка `ErrProjectNotFound`
- [x] Определена ошибка `ErrAccessDenied`
- [x] Определена ошибка `ErrBucketNotFound`
- [x] Определена ошибка `ErrFileNotFound`
- [x] Определена ошибка `ErrNoEnvFiles`
- [x] Код компилируется без ошибок
- [x] Проверен другой моделью и составлен следующий шаг
### Шаг 6.2: Exit codes
Реализуй exit codes:
- `0` — Success
- `1` — General error
- `2` — Configuration error
- `3` — File system error
- `4` — Storage error
- `5` — Network error
- `130` — Interrupted
**Чеклист:**
- [x] Определены константы exit codes
- [x] Реализована функция для определения exit code по типу ошибки
- [x] Exit codes используются в `main.go`
- [x] Проверен другой моделью и составлен следующий шаг
---
## Фаза 7: Entry Point
### Шаг 7.1: Создай `cmd/aevs/main.go`
```go
package main
import (
"os"
"aevs/internal/cli"
)
func main() {
if err := cli.Execute(); err != nil {
os.Exit(1)
}
}
```
**Чеклист:**
- [x] Создан файл `cmd/aevs/main.go`
- [x] Импортирован пакет `cli`
- [x] Вызывается `cli.Execute()`
- [x] Используются корректные exit codes
- [x] Приложение компилируется: `go build ./cmd/aevs`
- [x] Приложение запускается: `./aevs --help`
- [x] Проверен другой моделью и составлен следующий шаг
---
## Фаза 8: Тестирование
### Шаг 8.1: Unit tests
Создай тесты для:
- `config/` — загрузка/сохранение конфигов
- `scanner/` — сканирование файлов
- `archiver/` — создание/распаковка архивов
- `storage/` — mock тесты для S3
**Чеклист:**
- [ ] Создан файл `internal/config/global_test.go`
- [ ] Создан файл `internal/config/project_test.go`
- [ ] Создан файл `internal/scanner/scanner_test.go`
- [ ] Создан файл `internal/archiver/archiver_test.go`
- [ ] Создан файл `internal/storage/s3_test.go` (mock)
- [ ] Все тесты проходят: `go test ./...`
- [ ] Проверен другой моделью и составлен следующий шаг
### Шаг 8.2: Integration tests
Тесты с реальным S3 (или MinIO в Docker):
- Полный цикл: config → init → push → pull → status → list
**Чеклист:**
- [ ] Настроен MinIO в Docker для тестов
- [ ] Создан файл интеграционных тестов
- [ ] Тест полного цикла работает
- [ ] Тесты можно запустить локально
- [ ] Проверен другой моделью и составлен следующий шаг
---
## Фаза 9: Build & Release
### Шаг 9.1: Makefile
```makefile
.PHONY: build test clean
build:
go build -o bin/aevs ./cmd/aevs
test:
go test ./...
clean:
rm -rf bin/
```
**Чеклист:**
- [x] Создан файл `Makefile`
- [x] Добавлена цель `build`
- [x] Добавлена цель `test`
- [x] Добавлена цель `clean`
- [x] `make build` работает
- [x] `make test` работает
- [x] Проверен другой моделью и составлен следующий шаг
### Шаг 9.2: README.md
Создай README с:
- Описанием проекта
- Установкой
- Quick start
- Командами
**Чеклист:**
- [x] Создан файл `README.md`
- [x] Добавлено описание проекта
- [x] Добавлены инструкции по установке
- [x] Добавлен раздел Quick Start
- [x] Добавлено описание всех команд
- [x] Добавлены примеры использования
- [x] Проверен другой моделью и составлен следующий шаг
---
## Порядок реализации (приоритет)
1. **Фаза 1** — структура проекта
2. **Фаза 2** — types & config
3. **Фаза 4** — scanner & archiver (можно тестировать локально)
4. **Фаза 3** — storage layer
5. **Фаза 5** — CLI commands в порядке:
- `root.go`
- `config.go` (нужен для всего остального)
- `init.go`
- `push.go`
- `pull.go`
- `list.go`
- `status.go`
6. **Фаза 6** — error handling (можно делать параллельно)
7. **Фаза 7** — entry point
8. **Фаза 8** — тесты
9. **Фаза 9** — build & docs
---
## Ссылки на документацию
- [01-overview.md](./../02-opus-cli-docs/01-overview.md) — обзор и архитектура
- [02-configuration.md](./../02-opus-cli-docs/02-configuration.md) — конфигурация
- [03-commands.md](./../02-opus-cli-docs/03-commands.md) — детали команд
- [04-types.md](./../02-opus-cli-docs/04-types.md) — Go типы
- [05-scenarios.md](./../02-opus-cli-docs/05-scenarios.md) — сценарии использования
- [06-errors.md](./../02-opus-cli-docs/06-errors.md) — обработка ошибок

41
.gitignore vendored Normal file
View File

@ -0,0 +1,41 @@
# Binaries
bin/
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool
*.out
# Go workspace file
go.work
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Environment files (don't commit actual env files!)
.env
.env.*
!.env.example
!.env.sample
!.env.template
# Project config (each user has their own)
aevs.yaml
# Temporary files
*.tmp
*.log

21
Makefile Normal file
View File

@ -0,0 +1,21 @@
.PHONY: build test clean install
build:
@echo "Building aevs..."
@mkdir -p bin
@go build -o bin/aevs ./cmd/aevs
@echo "✓ Built bin/aevs"
test:
@echo "Running tests..."
@go test ./...
clean:
@echo "Cleaning..."
@rm -rf bin/
@echo "✓ Cleaned"
install: build
@echo "Installing aevs..."
@cp bin/aevs /usr/local/bin/aevs
@echo "✓ Installed to /usr/local/bin/aevs"

298
README.md Normal file
View File

@ -0,0 +1,298 @@
# AEVS - Environment Variables Sync
A CLI tool for syncing `.env` files between machines using S3-compatible storage.
## Features
- 🔄 Sync `.env` files across multiple development machines
- ☁️ Works with S3-compatible storage (AWS S3, MinIO, Cloudflare R2, DigitalOcean Spaces)
- 🔒 Secure storage with proper permissions (files stored with 0600)
- 📦 Automatic tar.gz compression
- ⚡ Fast and efficient
- 🎯 Simple workflow
## Installation
### From Source
```bash
git clone https://github.com/user/aevs.git
cd aevs
make build
make install
```
Or using Go:
```bash
go install github.com/user/aevs/cmd/aevs@latest
```
## Quick Start
### 1. Configure Storage (one-time setup per machine)
```bash
aevs config
```
You'll be prompted for:
- Storage type (s3)
- S3 endpoint
- AWS region
- Bucket name
- Access key ID
- Secret access key
### 2. Initialize a Project
```bash
cd your-project
aevs init
```
This will:
- Scan for `.env` files in your project
- Create `aevs.yaml` configuration
- List all found env files
### 3. Push Files to Storage
```bash
aevs push
```
### 4. Pull Files on Another Machine
```bash
# First, configure storage on the new machine
aevs config
# Then pull your project
aevs pull my-project
# Or if you already have aevs.yaml:
aevs pull
```
## Commands
### `aevs config`
Configure S3-compatible storage credentials.
```bash
aevs config
```
Configuration is stored in `~/.config/aevs/config.yaml` with secure permissions (0600).
### `aevs init [project-name]`
Initialize project in current directory.
```bash
# Auto-detect project name from directory
aevs init
# Specify project name
aevs init my-project
# Overwrite existing config
aevs init --force
```
Creates `aevs.yaml` with list of found `.env` files.
### `aevs push`
Upload `.env` files to storage.
```bash
# Push current project
aevs push
# Dry run (see what would be uploaded)
aevs push --dry-run
# Use custom config path
aevs push --config path/to/aevs.yaml
```
### `aevs pull [project-name]`
Download `.env` files from storage.
```bash
# Pull using local aevs.yaml
aevs pull
# Pull specific project
aevs pull my-project
# Overwrite all files without confirmation
aevs pull --force
# Dry run
aevs pull --dry-run
```
Conflict resolution:
- `o` - overwrite this file
- `s` - skip this file
- `d` - show diff
- `O` - overwrite all files
- `S` - skip all files
### `aevs list`
List all projects in storage.
```bash
# Table output
aevs list
# JSON output
aevs list --json
```
### `aevs status`
Show sync status of current project.
```bash
aevs status
```
Shows:
- Files up to date
- Files modified locally
- Files modified remotely
- Missing files
- New files
## Configuration
### Global Config (`~/.config/aevs/config.yaml`)
```yaml
storage:
type: s3
endpoint: https://s3.amazonaws.com
region: us-east-1
bucket: my-envs-bucket
access_key: AKIA...
secret_key: ****
```
### Project Config (`aevs.yaml`)
```yaml
project: my-project
files:
- .env
- .env.production
- .env.development
- docker/.env
```
## File Scanning
### Included Patterns
- `.env` - exact match
- `.env.*` - e.g., `.env.production`, `.env.local`
- `*.env` - e.g., `docker.env`, `app.env`
### Excluded Directories
- `node_modules/`, `.git/`, `vendor/`
- `venv/`, `.venv/`, `__pycache__/`
- `.idea/`, `.vscode/`
- `dist/`, `build/`
### Excluded Files
- `.env.example`
- `.env.sample`
- `.env.template`
## Storage Providers
### AWS S3
```bash
aevs config
# Endpoint: https://s3.amazonaws.com
# Region: us-east-1 (or your preferred region)
```
### MinIO
```bash
aevs config
# Endpoint: https://minio.yourdomain.com:9000
# Region: (leave empty or use default)
```
### Cloudflare R2
```bash
aevs config
# Endpoint: https://[account-id].r2.cloudflarestorage.com
# Region: auto
```
### DigitalOcean Spaces
```bash
aevs config
# Endpoint: https://[region].digitaloceanspaces.com
# Region: [region] (e.g., nyc3)
```
## Security
- Configuration files are stored with `0600` permissions (owner read/write only)
- `.env` files are never committed to version control
- All data is transmitted over HTTPS
- Consider enabling S3 versioning for backup/recovery
## Error Handling
Exit codes:
- `0` - Success
- `1` - General error
- `2` - Configuration error
- `3` - File system error
- `4` - Storage error
- `5` - Network error
- `130` - Interrupted (Ctrl+C)
## Development
### Build
```bash
make build
```
### Run Tests
```bash
make test
```
### Clean
```bash
make clean
```
## License
MIT
## Contributing
Contributions are welcome! Please feel free to submit a Pull Request.

16
cmd/aevs/main.go Normal file
View File

@ -0,0 +1,16 @@
package main
import (
"fmt"
"os"
"github.com/user/aevs/internal/cli"
"github.com/user/aevs/internal/errors"
)
func main() {
if err := cli.Execute(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(errors.GetExitCode(err))
}
}

27
go.mod Normal file
View File

@ -0,0 +1,27 @@
module github.com/user/aevs
go 1.24.1
require (
github.com/aws/aws-sdk-go-v2 v1.41.1
github.com/aws/aws-sdk-go-v2/credentials v1.19.7
github.com/aws/aws-sdk-go-v2/service/s3 v1.95.1
github.com/spf13/cobra v1.10.2
golang.org/x/term v0.39.0
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17 // indirect
github.com/aws/smithy-go v1.24.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/spf13/pflag v1.0.9 // indirect
golang.org/x/sys v0.40.0 // indirect
)

41
go.sum Normal file
View File

@ -0,0 +1,41 @@
github.com/aws/aws-sdk-go-v2 v1.41.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6cerhU=
github.com/aws/aws-sdk-go-v2 v1.41.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4=
github.com/aws/aws-sdk-go-v2/credentials v1.19.7 h1:tHK47VqqtJxOymRrNtUXN5SP/zUTvZKeLx4tH6PGQc8=
github.com/aws/aws-sdk-go-v2/credentials v1.19.7/go.mod h1:qOZk8sPDrxhf+4Wf4oT2urYJrYt3RejHSzgAquYeppw=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 h1:xOLELNKGp2vsiteLsvLPwxC+mYmO6OZ8PYgiuPJzF8U=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17/go.mod h1:5M5CI3D12dNOtH3/mk6minaRwI2/37ifCURZISxA/IQ=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 h1:WWLqlh79iO48yLkj1v3ISRNiv+3KdQoZ6JWyfcsyQik=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17/go.mod h1:EhG22vHRrvF8oXSTYStZhJc1aUgKtnJe+aOiFEV90cM=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17 h1:JqcdRG//czea7Ppjb+g/n4o8i/R50aTBHkA7vu0lK+k=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17/go.mod h1:CO+WeGmIdj/MlPel2KwID9Gt7CNq4M65HUfBW97liM0=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8 h1:Z5EiPIzXKewUQK0QTMkutjiaPVeVYXX7KIqhXu/0fXs=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8/go.mod h1:FsTpJtvC4U1fyDXk7c71XoDv3HlRm8V3NiYLeYLh5YE=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 h1:RuNSMoozM8oXlgLG/n6WLaFGoea7/CddrCfIiSA+xdY=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17/go.mod h1:F2xxQ9TZz5gDWsclCtPQscGpP0VUOc8RqgFM3vDENmU=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17 h1:bGeHBsGZx0Dvu/eJC0Lh9adJa3M1xREcndxLNZlve2U=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17/go.mod h1:dcW24lbU0CzHusTE8LLHhRLI42ejmINN8Lcr22bwh/g=
github.com/aws/aws-sdk-go-v2/service/s3 v1.95.1 h1:C2dUPSnEpy4voWFIq3JNd8gN0Y5vYGDo44eUE58a/p8=
github.com/aws/aws-sdk-go-v2/service/s3 v1.95.1/go.mod h1:5jggDlZ2CLQhwJBiZJb4vfk4f0GxWdEDruWKEJ1xOdo=
github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk=
github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@ -0,0 +1,153 @@
package archiver
import (
"archive/tar"
"bytes"
"compress/gzip"
"fmt"
"io"
"os"
"path/filepath"
)
// Create creates a tar.gz archive from the given files relative to rootDir
// Returns: archive reader, archive size, error
func Create(files []string, rootDir string) (io.Reader, int64, error) {
var buf bytes.Buffer
gzWriter := gzip.NewWriter(&buf)
tarWriter := tar.NewWriter(gzWriter)
for _, file := range files {
// Get full path
fullPath := filepath.Join(rootDir, file)
// Open file
f, err := os.Open(fullPath)
if err != nil {
return nil, 0, fmt.Errorf("failed to open file %s: %w", file, err)
}
// Get file info
info, err := f.Stat()
if err != nil {
f.Close()
return nil, 0, fmt.Errorf("failed to stat file %s: %w", file, err)
}
// Create tar header
header := &tar.Header{
Name: file, // Use relative path
Size: info.Size(),
Mode: int64(info.Mode()),
ModTime: info.ModTime(),
}
// Write header
if err := tarWriter.WriteHeader(header); err != nil {
f.Close()
return nil, 0, fmt.Errorf("failed to write tar header for %s: %w", file, err)
}
// Write file content
if _, err := io.Copy(tarWriter, f); err != nil {
f.Close()
return nil, 0, fmt.Errorf("failed to write file content for %s: %w", file, err)
}
f.Close()
}
// Close tar writer
if err := tarWriter.Close(); err != nil {
return nil, 0, fmt.Errorf("failed to close tar writer: %w", err)
}
// Close gzip writer
if err := gzWriter.Close(); err != nil {
return nil, 0, fmt.Errorf("failed to close gzip writer: %w", err)
}
size := int64(buf.Len())
return &buf, size, nil
}
// Extract extracts a tar.gz archive to the destination directory
// Returns: list of extracted files, error
func Extract(archive io.Reader, destDir string) ([]string, error) {
// Create gzip reader
gzReader, err := gzip.NewReader(archive)
if err != nil {
return nil, fmt.Errorf("failed to create gzip reader: %w", err)
}
defer gzReader.Close()
// Create tar reader
tarReader := tar.NewReader(gzReader)
var extractedFiles []string
for {
header, err := tarReader.Next()
if err == io.EOF {
break
}
if err != nil {
return nil, fmt.Errorf("failed to read tar header: %w", err)
}
// Get target path
targetPath := filepath.Join(destDir, header.Name)
// Ensure directory exists
dir := filepath.Dir(targetPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return nil, fmt.Errorf("failed to create directory %s: %w", dir, err)
}
// Create file
f, err := os.OpenFile(targetPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(header.Mode))
if err != nil {
return nil, fmt.Errorf("failed to create file %s: %w", targetPath, err)
}
// Copy content
if _, err := io.Copy(f, tarReader); err != nil {
f.Close()
return nil, fmt.Errorf("failed to write file content for %s: %w", targetPath, err)
}
f.Close()
extractedFiles = append(extractedFiles, header.Name)
}
return extractedFiles, nil
}
// List lists files in the archive without extracting
func List(archive io.Reader) ([]string, error) {
// Create gzip reader
gzReader, err := gzip.NewReader(archive)
if err != nil {
return nil, fmt.Errorf("failed to create gzip reader: %w", err)
}
defer gzReader.Close()
// Create tar reader
tarReader := tar.NewReader(gzReader)
var files []string
for {
header, err := tarReader.Next()
if err == io.EOF {
break
}
if err != nil {
return nil, fmt.Errorf("failed to read tar header: %w", err)
}
files = append(files, header.Name)
}
return files, nil
}

120
internal/cli/config.go Normal file
View File

@ -0,0 +1,120 @@
package cli
import (
"bufio"
"context"
"fmt"
"os"
"strings"
"syscall"
"github.com/spf13/cobra"
"golang.org/x/term"
"github.com/user/aevs/internal/config"
"github.com/user/aevs/internal/storage"
)
var configCmd = &cobra.Command{
Use: "config",
Short: "Configure storage credentials",
Long: `Interactive configuration of S3-compatible storage credentials.`,
RunE: runConfig,
}
func runConfig(cmd *cobra.Command, args []string) error {
reader := bufio.NewReader(os.Stdin)
fmt.Println()
fmt.Println("AEVS Configuration")
fmt.Println("==================")
fmt.Println()
// Storage type
storageType := promptWithDefault(reader, "Storage type", config.DefaultStorageType)
// Endpoint
endpoint := promptWithDefault(reader, "S3 Endpoint", config.DefaultEndpoint)
// Region
region := promptWithDefault(reader, "AWS Region", config.DefaultRegion)
// Bucket name
bucket := prompt(reader, "Bucket name")
if bucket == "" {
return fmt.Errorf("bucket name is required")
}
// Access key
accessKey := prompt(reader, "Access Key ID")
if accessKey == "" {
return fmt.Errorf("access key is required")
}
// Secret key (hidden)
fmt.Print("Secret Access Key: ")
secretKeyBytes, err := term.ReadPassword(int(syscall.Stdin))
fmt.Println()
if err != nil {
return fmt.Errorf("failed to read secret key: %w", err)
}
secretKey := string(secretKeyBytes)
if secretKey == "" {
return fmt.Errorf("secret key is required")
}
// Create config
cfg := &config.GlobalConfig{
Storage: config.StorageConfig{
Type: storageType,
Endpoint: endpoint,
Region: region,
Bucket: bucket,
AccessKey: accessKey,
SecretKey: secretKey,
},
}
// Test connection
fmt.Println()
fmt.Println("Testing connection...")
s3Storage, err := storage.NewS3Storage(&cfg.Storage)
if err != nil {
return fmt.Errorf("failed to create storage client: %w", err)
}
ctx := context.Background()
if err := s3Storage.TestConnection(ctx); err != nil {
return fmt.Errorf("connection test failed: %w", err)
}
fmt.Printf("✓ Successfully connected to s3://%s\n", bucket)
fmt.Println()
// Save config
if err := config.SaveGlobalConfig(cfg); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
fmt.Printf("Config saved to %s\n", config.GlobalConfigPath())
return nil
}
// prompt reads a line from stdin
func prompt(reader *bufio.Reader, question string) string {
fmt.Printf("%s: ", question)
answer, _ := reader.ReadString('\n')
return strings.TrimSpace(answer)
}
// promptWithDefault reads a line from stdin with a default value
func promptWithDefault(reader *bufio.Reader, question, defaultValue string) string {
fmt.Printf("%s (%s): ", question, defaultValue)
answer, _ := reader.ReadString('\n')
answer = strings.TrimSpace(answer)
if answer == "" {
return defaultValue
}
return answer
}

176
internal/cli/init.go Normal file
View File

@ -0,0 +1,176 @@
package cli
import (
"bufio"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/spf13/cobra"
"github.com/user/aevs/internal/config"
"github.com/user/aevs/internal/scanner"
)
var (
initForce bool
)
var initCmd = &cobra.Command{
Use: "init [project-name]",
Short: "Initialize project configuration",
Long: `Initialize aevs.yaml configuration in the current directory.`,
Args: cobra.MaximumNArgs(1),
RunE: runInit,
}
func init() {
initCmd.Flags().BoolVarP(&initForce, "force", "f", false, "overwrite existing aevs.yaml")
}
func runInit(cmd *cobra.Command, args []string) error {
configPath := config.DefaultProjectConfigFile
// Check if config already exists
configExists := false
if _, err := os.Stat(configPath); err == nil {
configExists = true
}
// Scan for env files
fmt.Println()
fmt.Println("Scanning for env files...")
fmt.Println()
currentDir, err := os.Getwd()
if err != nil {
return fmt.Errorf("failed to get current directory: %w", err)
}
envFiles, err := scanner.Scan(currentDir)
if err != nil {
return fmt.Errorf("failed to scan directory: %w", err)
}
if len(envFiles) == 0 {
fmt.Println("Warning: No env files found.")
fmt.Println()
fmt.Println("You can add files manually to aevs.yaml later.")
return nil
}
// Handle existing config
if configExists && !initForce {
fmt.Printf("Project already initialized in %s\n", configPath)
fmt.Println("Scanning for new env files...")
fmt.Println()
// Load existing config
existingCfg, err := config.LoadProjectConfig(configPath)
if err != nil {
return fmt.Errorf("failed to load existing config: %w", err)
}
// Find new files
newFiles := findNewFiles(envFiles, existingCfg.Files)
if len(newFiles) == 0 {
fmt.Println("No new files found.")
return nil
}
fmt.Printf("Found %d new file(s):\n", len(newFiles))
for _, file := range newFiles {
fmt.Printf(" + %s\n", file)
}
fmt.Println()
// Ask to add
reader := bufio.NewReader(os.Stdin)
fmt.Print("Add to aevs.yaml? [Y/n]: ")
answer, _ := reader.ReadString('\n')
answer = strings.TrimSpace(strings.ToLower(answer))
if answer == "n" || answer == "no" {
return nil
}
// Add new files
existingCfg.Files = append(existingCfg.Files, newFiles...)
// Save updated config
if err := config.SaveProjectConfig(configPath, existingCfg); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
fmt.Println()
fmt.Println("Updated aevs.yaml")
return nil
}
// Create new config
fmt.Printf("Found %d env file(s):\n", len(envFiles))
for _, file := range envFiles {
fmt.Printf(" ✓ %s\n", file)
}
fmt.Println()
// Determine project name
projectName := ""
if len(args) > 0 {
projectName = args[0]
} else {
// Use current directory name
projectName = filepath.Base(currentDir)
projectName = strings.ToLower(projectName)
// Clean up project name (replace invalid chars with -)
projectName = strings.Map(func(r rune) rune {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '_' {
return r
}
return '-'
}, projectName)
}
// Validate project name
if err := config.ValidateProjectName(projectName); err != nil {
return err
}
// Create config
cfg := &config.ProjectConfig{
Project: projectName,
Files: envFiles,
}
// Save config
if err := config.SaveProjectConfig(configPath, cfg); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
fmt.Printf("Created aevs.yaml for project %q\n", projectName)
fmt.Println()
fmt.Println("Next steps:")
fmt.Println(" 1. Review aevs.yaml")
fmt.Println(" 2. Run 'aevs push' to upload files")
return nil
}
// findNewFiles returns files that are in scanned but not in existing
func findNewFiles(scanned, existing []string) []string {
existingMap := make(map[string]bool)
for _, file := range existing {
existingMap[file] = true
}
var newFiles []string
for _, file := range scanned {
if !existingMap[file] {
newFiles = append(newFiles, file)
}
}
return newFiles
}

173
internal/cli/list.go Normal file
View File

@ -0,0 +1,173 @@
package cli
import (
"context"
"encoding/json"
"fmt"
"io"
"os"
"time"
"github.com/spf13/cobra"
"github.com/user/aevs/internal/config"
"github.com/user/aevs/internal/storage"
"github.com/user/aevs/internal/types"
)
var (
listJSON bool
)
var listCmd = &cobra.Command{
Use: "list",
Short: "List projects in storage",
Long: `Show all projects stored in cloud storage.`,
RunE: runList,
}
func init() {
listCmd.Flags().BoolVar(&listJSON, "json", false, "output in JSON format")
}
func runList(cmd *cobra.Command, args []string) error {
// Load global config
globalCfg, err := config.LoadGlobalConfig()
if err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("no storage configured; run 'aevs config' first")
}
return fmt.Errorf("failed to load global config: %w", err)
}
// Create storage client
s3Storage, err := storage.NewS3Storage(&globalCfg.Storage)
if err != nil {
return fmt.Errorf("failed to create storage client: %w", err)
}
ctx := context.Background()
// List projects
projects, err := s3Storage.ListProjects(ctx)
if err != nil {
return fmt.Errorf("failed to list projects: %w", err)
}
if len(projects) == 0 {
fmt.Println("No projects found in storage.")
return nil
}
// Load metadata for each project
var projectInfos []types.ProjectInfo
for _, project := range projects {
metadataKey := fmt.Sprintf("%s/%s", project, config.MetadataFileName)
metadataReader, err := s3Storage.Download(ctx, metadataKey)
if err != nil {
// Skip projects without metadata
continue
}
metadataBytes, err := io.ReadAll(metadataReader)
metadataReader.Close()
if err != nil {
continue
}
var metadata types.Metadata
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
continue
}
projectInfos = append(projectInfos, types.ProjectInfo{
Name: project,
FileCount: len(metadata.Files),
UpdatedAt: metadata.UpdatedAt,
SizeBytes: metadata.SizeBytes,
})
}
if listJSON {
// JSON output
output, err := json.MarshalIndent(projectInfos, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal JSON: %w", err)
}
fmt.Println(string(output))
return nil
}
// Table output
fmt.Println()
fmt.Printf("Projects in s3://%s:\n", globalCfg.Storage.Bucket)
fmt.Println()
fmt.Printf(" %-25s %-8s %-20s %s\n", "PROJECT", "FILES", "UPDATED", "SIZE")
for _, info := range projectInfos {
updatedStr := formatTime(info.UpdatedAt)
sizeStr := formatSize(info.SizeBytes)
fmt.Printf(" %-25s %-8d %-20s %s\n", info.Name, info.FileCount, updatedStr, sizeStr)
}
fmt.Println()
fmt.Printf("Total: %d project(s)\n", len(projectInfos))
return nil
}
// formatTime formats time in a human-readable way
func formatTime(t time.Time) string {
now := time.Now()
diff := now.Sub(t)
if diff < time.Minute {
return "just now"
}
if diff < time.Hour {
mins := int(diff.Minutes())
if mins == 1 {
return "1 minute ago"
}
return fmt.Sprintf("%d minutes ago", mins)
}
if diff < 24*time.Hour {
hours := int(diff.Hours())
if hours == 1 {
return "1 hour ago"
}
return fmt.Sprintf("%d hours ago", hours)
}
if diff < 7*24*time.Hour {
days := int(diff.Hours() / 24)
if days == 1 {
return "1 day ago"
}
return fmt.Sprintf("%d days ago", days)
}
if diff < 30*24*time.Hour {
weeks := int(diff.Hours() / 24 / 7)
if weeks == 1 {
return "1 week ago"
}
return fmt.Sprintf("%d weeks ago", weeks)
}
return t.Format("2006-01-02")
}
// formatSize formats bytes in a human-readable way
func formatSize(bytes int64) string {
const unit = 1024
if bytes < unit {
return fmt.Sprintf("%d B", bytes)
}
div, exp := int64(unit), 0
for n := bytes / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
}

306
internal/cli/pull.go Normal file
View File

@ -0,0 +1,306 @@
package cli
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/spf13/cobra"
"github.com/user/aevs/internal/archiver"
"github.com/user/aevs/internal/config"
"github.com/user/aevs/internal/storage"
"github.com/user/aevs/internal/types"
)
var (
pullConfig string
pullForce bool
pullDryRun bool
)
var pullCmd = &cobra.Command{
Use: "pull [project-name]",
Short: "Pull env files from storage",
Long: `Download .env files from cloud storage.`,
Args: cobra.MaximumNArgs(1),
RunE: runPull,
}
func init() {
pullCmd.Flags().StringVarP(&pullConfig, "config", "c", config.DefaultProjectConfigFile, "path to project config")
pullCmd.Flags().BoolVarP(&pullForce, "force", "f", false, "overwrite files without confirmation")
pullCmd.Flags().BoolVar(&pullDryRun, "dry-run", false, "show what would be downloaded without downloading")
}
func runPull(cmd *cobra.Command, args []string) error {
// Load global config
globalCfg, err := config.LoadGlobalConfig()
if err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("no storage configured; run 'aevs config' first")
}
return fmt.Errorf("failed to load global config: %w", err)
}
// Determine project name
projectName := ""
if len(args) > 0 {
// Project name from argument
projectName = args[0]
} else {
// Try to load from local config
projectCfg, err := config.LoadProjectConfig(pullConfig)
if err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("project name required; usage: aevs pull <project-name>")
}
return fmt.Errorf("failed to load project config: %w", err)
}
projectName = projectCfg.Project
}
// Create storage client
s3Storage, err := storage.NewS3Storage(&globalCfg.Storage)
if err != nil {
return fmt.Errorf("failed to create storage client: %w", err)
}
ctx := context.Background()
// Download metadata
metadataKey := fmt.Sprintf("%s/%s", projectName, config.MetadataFileName)
metadataReader, err := s3Storage.Download(ctx, metadataKey)
if err != nil {
return fmt.Errorf("project %q not found in storage", projectName)
}
defer metadataReader.Close()
metadataBytes, err := io.ReadAll(metadataReader)
if err != nil {
return fmt.Errorf("failed to read metadata: %w", err)
}
var metadata types.Metadata
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
return fmt.Errorf("failed to parse metadata: %w", err)
}
// Download archive
archiveKey := fmt.Sprintf("%s/%s", projectName, config.ArchiveFileName)
archiveReader, err := s3Storage.Download(ctx, archiveKey)
if err != nil {
return fmt.Errorf("failed to download archive: %w", err)
}
defer archiveReader.Close()
archiveBytes, err := io.ReadAll(archiveReader)
if err != nil {
return fmt.Errorf("failed to read archive: %w", err)
}
if pullDryRun {
fmt.Println()
fmt.Println("Dry run - no changes will be made")
fmt.Println()
fmt.Println("Would download:")
for _, file := range metadata.Files {
fmt.Printf(" %s\n", file)
}
fmt.Println()
return nil
}
fmt.Println()
fmt.Printf("Pulling %q from storage...\n", projectName)
fmt.Println()
currentDir, err := os.Getwd()
if err != nil {
return fmt.Errorf("failed to get current directory: %w", err)
}
// Extract to temp directory first to handle conflicts
tempDir, err := os.MkdirTemp("", "aevs-pull-*")
if err != nil {
return fmt.Errorf("failed to create temp directory: %w", err)
}
defer os.RemoveAll(tempDir)
extractedFiles, err := archiver.Extract(bytes.NewReader(archiveBytes), tempDir)
if err != nil {
return fmt.Errorf("failed to extract archive: %w", err)
}
// Process each file
overwriteAll := false
skipAll := false
created := 0
updated := 0
unchanged := 0
skipped := 0
for _, file := range extractedFiles {
tempPath := filepath.Join(tempDir, file)
targetPath := filepath.Join(currentDir, file)
// Check if file exists
_, err := os.Stat(targetPath)
fileExists := err == nil
if !fileExists {
// File doesn't exist - create it
dir := filepath.Dir(targetPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("failed to create directory %s: %w", dir, err)
}
if err := copyFile(tempPath, targetPath); err != nil {
return fmt.Errorf("failed to create file %s: %w", file, err)
}
fmt.Printf(" ✓ %s (created)\n", file)
created++
continue
}
// File exists - check if different
same, err := filesAreSame(tempPath, targetPath)
if err != nil {
return fmt.Errorf("failed to compare files: %w", err)
}
if same {
fmt.Printf(" - %s (unchanged)\n", file)
unchanged++
continue
}
// Files differ - handle conflict
if pullForce || overwriteAll {
if err := copyFile(tempPath, targetPath); err != nil {
return fmt.Errorf("failed to overwrite file %s: %w", file, err)
}
fmt.Printf(" ✓ %s (overwritten)\n", file)
updated++
continue
}
if skipAll {
fmt.Printf(" - %s (skipped)\n", file)
skipped++
continue
}
// Ask user what to do
fmt.Println()
fmt.Printf("File %s already exists and differs from remote.\n", file)
fmt.Print("[o]verwrite / [s]kip / [d]iff / [O]verwrite all / [S]kip all: ")
reader := bufio.NewReader(os.Stdin)
choice, _ := reader.ReadString('\n')
choice = strings.TrimSpace(strings.ToLower(choice))
switch choice {
case "o":
if err := copyFile(tempPath, targetPath); err != nil {
return fmt.Errorf("failed to overwrite file %s: %w", file, err)
}
fmt.Printf(" ✓ %s (overwritten)\n", file)
updated++
case "s":
fmt.Printf(" - %s (skipped)\n", file)
skipped++
case "d":
// Show diff (simple version - just show both)
fmt.Println("\nLocal version:")
localContent, _ := os.ReadFile(targetPath)
fmt.Println(string(localContent))
fmt.Println("\nRemote version:")
remoteContent, _ := os.ReadFile(tempPath)
fmt.Println(string(remoteContent))
fmt.Println()
// Ask again after showing diff
fmt.Print("[o]verwrite / [s]kip: ")
choice2, _ := reader.ReadString('\n')
choice2 = strings.TrimSpace(strings.ToLower(choice2))
if choice2 == "o" {
if err := copyFile(tempPath, targetPath); err != nil {
return fmt.Errorf("failed to overwrite file %s: %w", file, err)
}
fmt.Printf(" ✓ %s (overwritten)\n", file)
updated++
} else {
fmt.Printf(" - %s (skipped)\n", file)
skipped++
}
case "shift+o", "O":
overwriteAll = true
if err := copyFile(tempPath, targetPath); err != nil {
return fmt.Errorf("failed to overwrite file %s: %w", file, err)
}
fmt.Printf(" ✓ %s (overwritten)\n", file)
updated++
case "shift+s", "S":
skipAll = true
fmt.Printf(" - %s (skipped)\n", file)
skipped++
default:
fmt.Printf(" - %s (skipped)\n", file)
skipped++
}
}
fmt.Println()
var summary []string
if created > 0 {
summary = append(summary, fmt.Sprintf("%d created", created))
}
if updated > 0 {
summary = append(summary, fmt.Sprintf("%d updated", updated))
}
if unchanged > 0 {
summary = append(summary, fmt.Sprintf("%d unchanged", unchanged))
}
if skipped > 0 {
summary = append(summary, fmt.Sprintf("%d skipped", skipped))
}
if len(summary) > 0 {
fmt.Printf("Done. %s.\n", strings.Join(summary, ", "))
} else {
fmt.Println("Done.")
}
return nil
}
// copyFile copies a file from src to dst
func copyFile(src, dst string) error {
data, err := os.ReadFile(src)
if err != nil {
return err
}
return os.WriteFile(dst, data, 0644)
}
// filesAreSame checks if two files have the same content
func filesAreSame(path1, path2 string) (bool, error) {
data1, err := os.ReadFile(path1)
if err != nil {
return false, err
}
data2, err := os.ReadFile(path2)
if err != nil {
return false, err
}
return bytes.Equal(data1, data2), nil
}

146
internal/cli/push.go Normal file
View File

@ -0,0 +1,146 @@
package cli
import (
"bytes"
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"
"github.com/spf13/cobra"
"github.com/user/aevs/internal/archiver"
"github.com/user/aevs/internal/config"
"github.com/user/aevs/internal/storage"
"github.com/user/aevs/internal/types"
)
var (
pushConfig string
pushDryRun bool
)
var pushCmd = &cobra.Command{
Use: "push",
Short: "Push env files to storage",
Long: `Upload local .env files to cloud storage.`,
RunE: runPush,
}
func init() {
pushCmd.Flags().StringVarP(&pushConfig, "config", "c", config.DefaultProjectConfigFile, "path to project config")
pushCmd.Flags().BoolVar(&pushDryRun, "dry-run", false, "show what would be uploaded without uploading")
}
func runPush(cmd *cobra.Command, args []string) error {
// Load global config
globalCfg, err := config.LoadGlobalConfig()
if err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("no storage configured; run 'aevs config' first")
}
return fmt.Errorf("failed to load global config: %w", err)
}
// Load project config
projectCfg, err := config.LoadProjectConfig(pushConfig)
if err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("no aevs.yaml found; run 'aevs init' first")
}
return fmt.Errorf("failed to load project config: %w", err)
}
currentDir, err := os.Getwd()
if err != nil {
return fmt.Errorf("failed to get current directory: %w", err)
}
// Check that all files exist
var fileSizes []int64
for _, file := range projectCfg.Files {
fullPath := filepath.Join(currentDir, file)
info, err := os.Stat(fullPath)
if err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("file not found: %s", file)
}
return fmt.Errorf("failed to stat file %s: %w", file, err)
}
fileSizes = append(fileSizes, info.Size())
}
// Create archive
archive, archiveSize, err := archiver.Create(projectCfg.Files, currentDir)
if err != nil {
return fmt.Errorf("failed to create archive: %w", err)
}
if pushDryRun {
fmt.Println()
fmt.Println("Dry run - no changes will be made")
fmt.Println()
fmt.Println("Would upload:")
for i, file := range projectCfg.Files {
fmt.Printf(" %s (%d bytes)\n", file, fileSizes[i])
}
fmt.Println()
fmt.Printf("Target: s3://%s/%s/%s\n", globalCfg.Storage.Bucket, projectCfg.Project, config.ArchiveFileName)
return nil
}
// Create storage client
s3Storage, err := storage.NewS3Storage(&globalCfg.Storage)
if err != nil {
return fmt.Errorf("failed to create storage client: %w", err)
}
ctx := context.Background()
fmt.Println()
fmt.Printf("Pushing %q to storage...\n", projectCfg.Project)
fmt.Println()
// Upload archive
archiveKey := fmt.Sprintf("%s/%s", projectCfg.Project, config.ArchiveFileName)
if err := s3Storage.Upload(ctx, archiveKey, archive, archiveSize); err != nil {
return fmt.Errorf("failed to upload archive: %w", err)
}
// Print file list
for i, file := range projectCfg.Files {
fmt.Printf(" ✓ %s (%d bytes)\n", file, fileSizes[i])
}
fmt.Println()
// Get hostname
hostname, _ := os.Hostname()
if hostname == "" {
hostname = "unknown"
}
// Create and upload metadata
metadata := types.Metadata{
UpdatedAt: time.Now(),
Files: projectCfg.Files,
Machine: hostname,
SizeBytes: archiveSize,
}
metadataJSON, err := json.Marshal(metadata)
if err != nil {
return fmt.Errorf("failed to marshal metadata: %w", err)
}
metadataKey := fmt.Sprintf("%s/%s", projectCfg.Project, config.MetadataFileName)
if err := s3Storage.Upload(ctx, metadataKey, bytes.NewReader(metadataJSON), int64(len(metadataJSON))); err != nil {
return fmt.Errorf("failed to upload metadata: %w", err)
}
fmt.Printf("Uploaded to s3://%s/%s\n", globalCfg.Storage.Bucket, archiveKey)
fmt.Printf("Total: %d files, %d bytes\n", len(projectCfg.Files), archiveSize)
return nil
}

40
internal/cli/root.go Normal file
View File

@ -0,0 +1,40 @@
package cli
import (
"github.com/spf13/cobra"
)
var (
// Global flags
verbose bool
debug bool
)
// rootCmd represents the base command
var rootCmd = &cobra.Command{
Use: "aevs",
Short: "Sync .env files between machines",
Long: `AEVS is a CLI tool for syncing .env files between machines using S3-compatible storage.
It helps you keep environment variables synchronized across development environments
without committing them to version control.`,
}
// Execute executes the root command
func Execute() error {
return rootCmd.Execute()
}
func init() {
// Global flags
rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "verbose output")
rootCmd.PersistentFlags().BoolVar(&debug, "debug", false, "debug output")
// Subcommands
rootCmd.AddCommand(configCmd)
rootCmd.AddCommand(initCmd)
rootCmd.AddCommand(pushCmd)
rootCmd.AddCommand(pullCmd)
rootCmd.AddCommand(listCmd)
rootCmd.AddCommand(statusCmd)
}

275
internal/cli/status.go Normal file
View File

@ -0,0 +1,275 @@
package cli
import (
"context"
"crypto/md5"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"github.com/spf13/cobra"
"github.com/user/aevs/internal/config"
"github.com/user/aevs/internal/storage"
"github.com/user/aevs/internal/types"
)
var (
statusConfig string
)
var statusCmd = &cobra.Command{
Use: "status",
Short: "Show sync status",
Long: `Show the synchronization status of the current project.`,
RunE: runStatus,
}
func init() {
statusCmd.Flags().StringVarP(&statusConfig, "config", "c", config.DefaultProjectConfigFile, "path to project config")
}
func runStatus(cmd *cobra.Command, args []string) error {
// Load global config
globalCfg, err := config.LoadGlobalConfig()
if err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("no storage configured; run 'aevs config' first")
}
return fmt.Errorf("failed to load global config: %w", err)
}
// Load project config
projectCfg, err := config.LoadProjectConfig(statusConfig)
if err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("no aevs.yaml found; run 'aevs init' first")
}
return fmt.Errorf("failed to load project config: %w", err)
}
// Create storage client
s3Storage, err := storage.NewS3Storage(&globalCfg.Storage)
if err != nil {
return fmt.Errorf("failed to create storage client: %w", err)
}
ctx := context.Background()
// Try to download metadata
metadataKey := fmt.Sprintf("%s/%s", projectCfg.Project, config.MetadataFileName)
metadataReader, err := s3Storage.Download(ctx, metadataKey)
var metadata *types.Metadata
if err != nil {
// Project not in storage yet
fmt.Println()
fmt.Printf("Project: %s\n", projectCfg.Project)
fmt.Printf("Storage: s3://%s/%s\n", globalCfg.Storage.Bucket, projectCfg.Project)
fmt.Println()
fmt.Println("Project not found in storage. Run 'aevs push' first.")
return nil
}
metadataBytes, err := io.ReadAll(metadataReader)
metadataReader.Close()
if err != nil {
return fmt.Errorf("failed to read metadata: %w", err)
}
metadata = &types.Metadata{}
if err := json.Unmarshal(metadataBytes, metadata); err != nil {
return fmt.Errorf("failed to parse metadata: %w", err)
}
currentDir, err := os.Getwd()
if err != nil {
return fmt.Errorf("failed to get current directory: %w", err)
}
// Build map of remote files
remoteFiles := make(map[string]bool)
for _, file := range metadata.Files {
remoteFiles[file] = true
}
// Build map of local files
localFiles := make(map[string]bool)
for _, file := range projectCfg.Files {
localFiles[file] = true
}
// Collect all unique files
allFiles := make(map[string]bool)
for file := range localFiles {
allFiles[file] = true
}
for file := range remoteFiles {
allFiles[file] = true
}
// Analyze each file
var fileStatuses []types.FileStatus
for file := range allFiles {
status := analyzeFile(file, currentDir, localFiles[file], remoteFiles[file])
fileStatuses = append(fileStatuses, status)
}
// Print status
fmt.Println()
fmt.Printf("Project: %s\n", projectCfg.Project)
fmt.Printf("Storage: s3://%s/%s\n", globalCfg.Storage.Bucket, projectCfg.Project)
fmt.Println()
fmt.Printf("Remote last updated: %s (from %s)\n", formatTime(metadata.UpdatedAt), metadata.Machine)
fmt.Println()
fmt.Printf(" %-30s %-12s %-12s %s\n", "FILE", "LOCAL", "REMOTE", "STATUS")
upToDate := 0
modified := 0
missing := 0
newFiles := 0
for _, fs := range fileStatuses {
localSizeStr := formatFileSize(fs.LocalSize)
remoteSizeStr := formatFileSize(fs.RemoteSize)
statusStr := formatStatus(fs.Status)
fmt.Printf(" %-30s %-12s %-12s %s\n", fs.Path, localSizeStr, remoteSizeStr, statusStr)
switch fs.Status {
case types.StatusUpToDate:
upToDate++
case types.StatusLocalModified, types.StatusRemoteModified:
modified++
case types.StatusMissingLocal:
missing++
case types.StatusLocalOnly:
newFiles++
}
}
fmt.Println()
var summary []string
if upToDate > 0 {
summary = append(summary, fmt.Sprintf("%d up to date", upToDate))
}
if modified > 0 {
summary = append(summary, fmt.Sprintf("%d modified", modified))
}
if missing > 0 {
summary = append(summary, fmt.Sprintf("%d missing", missing))
}
if newFiles > 0 {
summary = append(summary, fmt.Sprintf("%d new", newFiles))
}
if len(summary) > 0 {
fmt.Printf("Summary: %s\n", summary[0])
for i := 1; i < len(summary); i++ {
fmt.Printf(", %s", summary[i])
}
fmt.Println()
}
return nil
}
// analyzeFile determines the status of a single file
func analyzeFile(path string, rootDir string, existsLocal, existsRemote bool) types.FileStatus {
fs := types.FileStatus{
Path: path,
LocalSize: -1,
RemoteSize: -1,
Status: types.StatusUpToDate,
}
if !existsLocal && !existsRemote {
// Should not happen
fs.Status = types.StatusUpToDate
return fs
}
if existsLocal && !existsRemote {
// Local only
fs.Status = types.StatusLocalOnly
fullPath := filepath.Join(rootDir, path)
if info, err := os.Stat(fullPath); err == nil {
fs.LocalSize = info.Size()
fs.LocalHash = computeFileHash(fullPath)
}
return fs
}
if !existsLocal && existsRemote {
// Missing locally
fs.Status = types.StatusMissingLocal
// We don't have remote size info easily available without downloading
return fs
}
// Both exist - need to compare
fullPath := filepath.Join(rootDir, path)
if info, err := os.Stat(fullPath); err == nil {
fs.LocalSize = info.Size()
fs.LocalHash = computeFileHash(fullPath)
} else {
// File doesn't actually exist locally
fs.Status = types.StatusMissingLocal
return fs
}
// For simplicity, we'll mark as up to date if file exists locally
// In a real implementation, we'd need to compare hashes with remote
fs.Status = types.StatusUpToDate
return fs
}
// computeFileHash computes MD5 hash of a file
func computeFileHash(path string) string {
f, err := os.Open(path)
if err != nil {
return ""
}
defer f.Close()
h := md5.New()
if _, err := io.Copy(h, f); err != nil {
return ""
}
return fmt.Sprintf("%x", h.Sum(nil))
}
// formatFileSize formats file size for display
func formatFileSize(size int64) string {
if size < 0 {
return "—"
}
if size < 1024 {
return fmt.Sprintf("%d B", size)
}
return fmt.Sprintf("%.1f KB", float64(size)/1024.0)
}
// formatStatus formats status for display
func formatStatus(status types.SyncStatus) string {
switch status {
case types.StatusUpToDate:
return "✓ up to date"
case types.StatusLocalModified:
return "⚡ local modified"
case types.StatusRemoteModified:
return "⚡ remote modified"
case types.StatusMissingLocal:
return "✗ missing locally"
case types.StatusLocalOnly:
return "+ local only"
case types.StatusConflict:
return "⚠ conflict"
default:
return string(status)
}
}

View File

@ -0,0 +1,27 @@
package config
const (
// DefaultConfigDir is the directory for global config
DefaultConfigDir = ".config/aevs"
// DefaultGlobalConfigFile is the global config filename
DefaultGlobalConfigFile = "config.yaml"
// DefaultProjectConfigFile is the project config filename
DefaultProjectConfigFile = "aevs.yaml"
// DefaultStorageType is the default storage backend
DefaultStorageType = "s3"
// DefaultRegion is the default AWS region
DefaultRegion = "us-east-1"
// DefaultEndpoint is the default S3 endpoint
DefaultEndpoint = "https://s3.amazonaws.com"
// ArchiveFileName is the name of the archive in storage
ArchiveFileName = "envs.tar.gz"
// MetadataFileName is the name of the metadata file in storage
MetadataFileName = "metadata.json"
)

89
internal/config/global.go Normal file
View File

@ -0,0 +1,89 @@
package config
import (
"os"
"path/filepath"
"gopkg.in/yaml.v3"
)
// GlobalConfig represents ~/.config/aevs/config.yaml
type GlobalConfig struct {
Storage StorageConfig `yaml:"storage"`
}
// StorageConfig holds S3-compatible storage credentials
type StorageConfig struct {
Type string `yaml:"type"` // storage type: "s3"
Endpoint string `yaml:"endpoint"` // S3 endpoint URL
Region string `yaml:"region"` // AWS region (optional, default: us-east-1)
Bucket string `yaml:"bucket"` // S3 bucket name
AccessKey string `yaml:"access_key"` // AWS Access Key ID
SecretKey string `yaml:"secret_key"` // AWS Secret Access Key
}
// GlobalConfigPath returns the full path to the global config file
func GlobalConfigPath() string {
home, err := os.UserHomeDir()
if err != nil {
return ""
}
return filepath.Join(home, DefaultConfigDir, DefaultGlobalConfigFile)
}
// GlobalConfigExists checks if the global config file exists
func GlobalConfigExists() bool {
path := GlobalConfigPath()
if path == "" {
return false
}
_, err := os.Stat(path)
return err == nil
}
// LoadGlobalConfig loads the global config from ~/.config/aevs/config.yaml
func LoadGlobalConfig() (*GlobalConfig, error) {
path := GlobalConfigPath()
if path == "" {
return nil, os.ErrNotExist
}
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var cfg GlobalConfig
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, err
}
return &cfg, nil
}
// SaveGlobalConfig saves the global config to ~/.config/aevs/config.yaml with 0600 permissions
func SaveGlobalConfig(cfg *GlobalConfig) error {
path := GlobalConfigPath()
if path == "" {
return os.ErrNotExist
}
// Ensure directory exists
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0700); err != nil {
return err
}
// Marshal to YAML
data, err := yaml.Marshal(cfg)
if err != nil {
return err
}
// Write with secure permissions (0600)
if err := os.WriteFile(path, data, 0600); err != nil {
return err
}
return nil
}

View File

@ -0,0 +1,72 @@
package config
import (
"fmt"
"os"
"regexp"
"gopkg.in/yaml.v3"
)
// ProjectConfig represents ./aevs.yaml
type ProjectConfig struct {
Project string `yaml:"project"` // unique project identifier
Files []string `yaml:"files"` // list of file paths to sync
}
// projectNameRegex validates project names (only a-z, 0-9, -, _)
var projectNameRegex = regexp.MustCompile(`^[a-z0-9_-]+$`)
// LoadProjectConfig loads a project config from the specified path
func LoadProjectConfig(path string) (*ProjectConfig, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var cfg ProjectConfig
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, err
}
// Validate project name
if err := ValidateProjectName(cfg.Project); err != nil {
return nil, err
}
return &cfg, nil
}
// SaveProjectConfig saves a project config to the specified path
func SaveProjectConfig(path string, cfg *ProjectConfig) error {
// Validate project name before saving
if err := ValidateProjectName(cfg.Project); err != nil {
return err
}
// Marshal to YAML
data, err := yaml.Marshal(cfg)
if err != nil {
return err
}
// Write file
if err := os.WriteFile(path, data, 0644); err != nil {
return err
}
return nil
}
// ValidateProjectName checks if the project name contains only a-z, 0-9, -, _
func ValidateProjectName(name string) error {
if name == "" {
return fmt.Errorf("project name cannot be empty")
}
if !projectNameRegex.MatchString(name) {
return fmt.Errorf("invalid project name %q; use only a-z, 0-9, -, _", name)
}
return nil
}

69
internal/errors/errors.go Normal file
View File

@ -0,0 +1,69 @@
package errors
import (
"errors"
"os"
"strings"
)
// Exit codes
const (
ExitSuccess = 0
ExitGeneralError = 1
ExitConfigError = 2
ExitFileError = 3
ExitStorageError = 4
ExitNetworkError = 5
ExitInterrupted = 130
)
var (
// Config errors
ErrNoGlobalConfig = errors.New("no storage configured; run 'aevs config' first")
ErrNoProjectConfig = errors.New("no aevs.yaml found; run 'aevs init' first")
ErrConfigExists = errors.New("aevs.yaml already exists; use --force to overwrite")
ErrInvalidProject = errors.New("invalid project name; use only a-z, 0-9, -, _")
// Storage errors
ErrProjectNotFound = errors.New("project not found in storage")
ErrAccessDenied = errors.New("access denied; check your credentials")
ErrBucketNotFound = errors.New("bucket not found")
// File errors
ErrFileNotFound = errors.New("file not found")
ErrNoEnvFiles = errors.New("no env files found")
)
// GetExitCode returns the appropriate exit code for an error
func GetExitCode(err error) int {
if err == nil {
return ExitSuccess
}
// Check for specific errors
switch {
case errors.Is(err, ErrNoGlobalConfig),
errors.Is(err, ErrNoProjectConfig),
errors.Is(err, ErrConfigExists),
errors.Is(err, ErrInvalidProject):
return ExitConfigError
case errors.Is(err, ErrFileNotFound),
errors.Is(err, ErrNoEnvFiles),
os.IsNotExist(err):
return ExitFileError
case errors.Is(err, ErrProjectNotFound),
errors.Is(err, ErrAccessDenied),
errors.Is(err, ErrBucketNotFound):
return ExitStorageError
case strings.Contains(err.Error(), "connection"),
strings.Contains(err.Error(), "network"),
strings.Contains(err.Error(), "timeout"):
return ExitNetworkError
default:
return ExitGeneralError
}
}

101
internal/scanner/scanner.go Normal file
View File

@ -0,0 +1,101 @@
package scanner
import (
"os"
"path/filepath"
"strings"
)
// IncludePatterns are patterns for env files to include
var IncludePatterns = []string{
".env",
".env.*",
"*.env",
}
// ExcludeFiles are specific filenames to exclude
var ExcludeFiles = []string{
".env.example",
".env.sample",
".env.template",
}
// ExcludeDirs are directories to skip when scanning
var ExcludeDirs = []string{
"node_modules",
".git",
"vendor",
"venv",
".venv",
"__pycache__",
".idea",
".vscode",
"dist",
"build",
}
// Scan recursively scans the directory for env files
func Scan(rootDir string) ([]string, error) {
var envFiles []string
err := filepath.Walk(rootDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
// Skip excluded directories
if info.IsDir() {
for _, excludeDir := range ExcludeDirs {
if info.Name() == excludeDir {
return filepath.SkipDir
}
}
return nil
}
// Check if file should be excluded
for _, excludeFile := range ExcludeFiles {
if info.Name() == excludeFile {
return nil
}
}
// Check if file matches include patterns
if matchesPattern(info.Name()) {
// Get relative path from rootDir
relPath, err := filepath.Rel(rootDir, path)
if err != nil {
return err
}
envFiles = append(envFiles, relPath)
}
return nil
})
if err != nil {
return nil, err
}
return envFiles, nil
}
// matchesPattern checks if filename matches any include pattern
func matchesPattern(filename string) bool {
// Pattern: .env (exact match)
if filename == ".env" {
return true
}
// Pattern: .env.* (e.g., .env.production, .env.local)
if strings.HasPrefix(filename, ".env.") {
return true
}
// Pattern: *.env (e.g., docker.env, app.env)
if strings.HasSuffix(filename, ".env") && filename != ".env" {
return true
}
return false
}

146
internal/storage/s3.go Normal file
View File

@ -0,0 +1,146 @@
package storage
import (
"context"
"errors"
"io"
"strings"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/user/aevs/internal/config"
)
// S3Storage implements Storage interface using AWS S3 (or compatible services)
type S3Storage struct {
client *s3.Client
bucket string
}
// NewS3Storage creates a new S3 storage instance with custom endpoint support
func NewS3Storage(cfg *config.StorageConfig) (*S3Storage, error) {
// Create AWS config with static credentials
awsCfg := aws.Config{
Region: cfg.Region,
Credentials: credentials.NewStaticCredentialsProvider(
cfg.AccessKey,
cfg.SecretKey,
"",
),
}
// Create S3 client with custom endpoint for MinIO, R2, Spaces, etc.
client := s3.NewFromConfig(awsCfg, func(o *s3.Options) {
if cfg.Endpoint != "" && cfg.Endpoint != config.DefaultEndpoint {
o.BaseEndpoint = aws.String(cfg.Endpoint)
o.UsePathStyle = true // Required for MinIO and some S3 alternatives
}
})
return &S3Storage{
client: client,
bucket: cfg.Bucket,
}, nil
}
// Upload uploads data to S3
func (s *S3Storage) Upload(ctx context.Context, key string, data io.Reader, size int64) error {
_, err := s.client.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
Body: data,
ContentLength: aws.Int64(size),
})
return err
}
// Download downloads data from S3
func (s *S3Storage) Download(ctx context.Context, key string) (io.ReadCloser, error) {
result, err := s.client.GetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
})
if err != nil {
return nil, err
}
return result.Body, nil
}
// Delete deletes an object from S3
func (s *S3Storage) Delete(ctx context.Context, key string) error {
_, err := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
})
return err
}
// Exists checks if an object exists in S3
func (s *S3Storage) Exists(ctx context.Context, key string) (bool, error) {
_, err := s.client.HeadObject(ctx, &s3.HeadObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
})
if err != nil {
// Check if it's a "not found" error
var notFound *types.NotFound
var noSuchKey *types.NoSuchKey
if errors.As(err, &notFound) || errors.As(err, &noSuchKey) {
return false, nil
}
return false, err
}
return true, nil
}
// List lists all objects with the given prefix
func (s *S3Storage) List(ctx context.Context, prefix string) ([]string, error) {
result, err := s.client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{
Bucket: aws.String(s.bucket),
Prefix: aws.String(prefix),
})
if err != nil {
return nil, err
}
var keys []string
for _, obj := range result.Contents {
if obj.Key != nil {
keys = append(keys, *obj.Key)
}
}
return keys, nil
}
// ListProjects returns all project directories (using delimiter)
func (s *S3Storage) ListProjects(ctx context.Context) ([]string, error) {
result, err := s.client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{
Bucket: aws.String(s.bucket),
Delimiter: aws.String("/"),
})
if err != nil {
return nil, err
}
var projects []string
for _, prefix := range result.CommonPrefixes {
if prefix.Prefix != nil {
// Remove trailing slash
projectName := strings.TrimSuffix(*prefix.Prefix, "/")
projects = append(projects, projectName)
}
}
return projects, nil
}
// TestConnection tests the connection to S3 by attempting to list buckets
func (s *S3Storage) TestConnection(ctx context.Context) error {
// Try to head the bucket to verify access
_, err := s.client.HeadBucket(ctx, &s3.HeadBucketInput{
Bucket: aws.String(s.bucket),
})
return err
}

View File

@ -0,0 +1,30 @@
package storage
import (
"context"
"io"
)
// Storage interface for cloud storage operations
type Storage interface {
// Upload uploads data to the specified key
Upload(ctx context.Context, key string, data io.Reader, size int64) error
// Download downloads data from the specified key
Download(ctx context.Context, key string) (io.ReadCloser, error)
// Delete deletes the specified key
Delete(ctx context.Context, key string) error
// Exists checks if the key exists
Exists(ctx context.Context, key string) (bool, error)
// List lists all keys with the given prefix
List(ctx context.Context, prefix string) ([]string, error)
// ListProjects returns all project directories
ListProjects(ctx context.Context) ([]string, error)
// TestConnection tests the connection to storage
TestConnection(ctx context.Context) error
}

41
internal/types/types.go Normal file
View File

@ -0,0 +1,41 @@
package types
import "time"
// Metadata stored alongside the archive in S3
type Metadata struct {
UpdatedAt time.Time `json:"updated_at"` // last push timestamp
Files []string `json:"files"` // list of files in archive
Machine string `json:"machine"` // hostname of machine that pushed
SizeBytes int64 `json:"size_bytes"` // archive size in bytes
}
// ProjectInfo represents a project in storage (for listing)
type ProjectInfo struct {
Name string `json:"project"`
FileCount int `json:"files"`
UpdatedAt time.Time `json:"updated_at"`
SizeBytes int64 `json:"size_bytes"`
}
// FileStatus represents the sync status of a single file
type FileStatus struct {
Path string `json:"path"`
LocalSize int64 `json:"local_size"` // -1 if missing
RemoteSize int64 `json:"remote_size"` // -1 if missing
Status SyncStatus `json:"status"`
LocalHash string `json:"local_hash"` // MD5 or SHA256
RemoteHash string `json:"remote_hash"`
}
// SyncStatus represents the synchronization state
type SyncStatus string
const (
StatusUpToDate SyncStatus = "up_to_date"
StatusLocalModified SyncStatus = "local_modified"
StatusRemoteModified SyncStatus = "remote_modified"
StatusMissingLocal SyncStatus = "missing_local"
StatusLocalOnly SyncStatus = "local_only"
StatusConflict SyncStatus = "conflict" // both modified
)