init
This commit is contained in:
commit
e10b389661
|
|
@ -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"`
|
||||
}
|
||||
|
||||
```
|
||||
|
|
@ -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;
|
||||
|
|
@ -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: конфиг только один, путь до него либо указан флагом, либо он в директории из которой вызывается утилита, наследование не нужно
|
||||
|
|
@ -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: давай указывать его в конфиге;
|
||||
|
|
@ -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
|
||||
)
|
||||
```
|
||||
|
|
@ -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: оба создают новые версии;
|
||||
|
|
@ -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 хранилище проекты для другого ключа, чтобы на тестах убедиться, что пользователь его не видит)
|
||||
|
|
@ -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` заново
|
||||
|
|
@ -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
|
||||
)
|
||||
```
|
||||
|
|
@ -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`
|
||||
|
|
@ -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.` |
|
||||
|
|
@ -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")
|
||||
)
|
||||
```
|
||||
|
|
@ -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>` в будущем.
|
||||
|
|
@ -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 всё будет загружено заново (идемпотентность).
|
||||
|
|
@ -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)
|
||||
|
|
@ -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) — обработка ошибок
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
|
|
@ -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.
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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=
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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])
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
)
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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, ¬Found) || 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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
Loading…
Reference in New Issue