From e10b38966185fa99474f30f76c1dc2c99685b00f Mon Sep 17 00:00:00 2001 From: naudachu Date: Wed, 28 Jan 2026 16:24:21 +0500 Subject: [PATCH] init --- .docs/00-initial-qa-session/init.md | 124 ++++ .docs/00-initial-qa-session/q-001.md | 47 ++ .docs/00-initial-qa-session/q-002.md | 131 ++++ .docs/00-initial-qa-session/q-003.md | 116 ++++ .docs/01-opus-docs/01-sprint.md | 360 ++++++++++ .docs/01-opus-docs/02-q.md | 94 +++ .docs/01-opus-docs/03-q.md | 79 +++ .docs/01-opus-docs/04-q.md | 42 ++ .docs/02-opus-cli-docs/01-overview.md | 109 +++ .docs/02-opus-cli-docs/02-configuration.md | 179 +++++ .docs/02-opus-cli-docs/03-commands.md | 482 ++++++++++++++ .docs/02-opus-cli-docs/04-types.md | 291 ++++++++ .docs/02-opus-cli-docs/05-scenarios.md | 382 +++++++++++ .docs/02-opus-cli-docs/06-errors.md | 273 ++++++++ .docs/02-opus-cli-docs/README.md | 62 ++ .docs/03-opus-plans/implementation-plan.md | 730 +++++++++++++++++++++ .gitignore | 41 ++ Makefile | 21 + README.md | 298 +++++++++ cmd/aevs/main.go | 16 + go.mod | 27 + go.sum | 41 ++ internal/archiver/archiver.go | 153 +++++ internal/cli/config.go | 120 ++++ internal/cli/init.go | 176 +++++ internal/cli/list.go | 173 +++++ internal/cli/pull.go | 306 +++++++++ internal/cli/push.go | 146 +++++ internal/cli/root.go | 40 ++ internal/cli/status.go | 275 ++++++++ internal/config/constants.go | 27 + internal/config/global.go | 89 +++ internal/config/project.go | 72 ++ internal/errors/errors.go | 69 ++ internal/scanner/scanner.go | 101 +++ internal/storage/s3.go | 146 +++++ internal/storage/storage.go | 30 + internal/types/types.go | 41 ++ 38 files changed, 5909 insertions(+) create mode 100644 .docs/00-initial-qa-session/init.md create mode 100644 .docs/00-initial-qa-session/q-001.md create mode 100644 .docs/00-initial-qa-session/q-002.md create mode 100644 .docs/00-initial-qa-session/q-003.md create mode 100644 .docs/01-opus-docs/01-sprint.md create mode 100644 .docs/01-opus-docs/02-q.md create mode 100644 .docs/01-opus-docs/03-q.md create mode 100644 .docs/01-opus-docs/04-q.md create mode 100644 .docs/02-opus-cli-docs/01-overview.md create mode 100644 .docs/02-opus-cli-docs/02-configuration.md create mode 100644 .docs/02-opus-cli-docs/03-commands.md create mode 100644 .docs/02-opus-cli-docs/04-types.md create mode 100644 .docs/02-opus-cli-docs/05-scenarios.md create mode 100644 .docs/02-opus-cli-docs/06-errors.md create mode 100644 .docs/02-opus-cli-docs/README.md create mode 100644 .docs/03-opus-plans/implementation-plan.md create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 README.md create mode 100644 cmd/aevs/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/archiver/archiver.go create mode 100644 internal/cli/config.go create mode 100644 internal/cli/init.go create mode 100644 internal/cli/list.go create mode 100644 internal/cli/pull.go create mode 100644 internal/cli/push.go create mode 100644 internal/cli/root.go create mode 100644 internal/cli/status.go create mode 100644 internal/config/constants.go create mode 100644 internal/config/global.go create mode 100644 internal/config/project.go create mode 100644 internal/errors/errors.go create mode 100644 internal/scanner/scanner.go create mode 100644 internal/storage/s3.go create mode 100644 internal/storage/storage.go create mode 100644 internal/types/types.go diff --git a/.docs/00-initial-qa-session/init.md b/.docs/00-initial-qa-session/init.md new file mode 100644 index 0000000..eba19a4 --- /dev/null +++ b/.docs/00-initial-qa-session/init.md @@ -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"` +} + +``` \ No newline at end of file diff --git a/.docs/00-initial-qa-session/q-001.md b/.docs/00-initial-qa-session/q-001.md new file mode 100644 index 0000000..af8775f --- /dev/null +++ b/.docs/00-initial-qa-session/q-001.md @@ -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; diff --git a/.docs/00-initial-qa-session/q-002.md b/.docs/00-initial-qa-session/q-002.md new file mode 100644 index 0000000..93f3848 --- /dev/null +++ b/.docs/00-initial-qa-session/q-002.md @@ -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: конфиг только один, путь до него либо указан флагом, либо он в директории из которой вызывается утилита, наследование не нужно diff --git a/.docs/00-initial-qa-session/q-003.md b/.docs/00-initial-qa-session/q-003.md new file mode 100644 index 0000000..3a03947 --- /dev/null +++ b/.docs/00-initial-qa-session/q-003.md @@ -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: давай указывать его в конфиге; diff --git a/.docs/01-opus-docs/01-sprint.md b/.docs/01-opus-docs/01-sprint.md new file mode 100644 index 0000000..d4d78ab --- /dev/null +++ b/.docs/01-opus-docs/01-sprint.md @@ -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 ` — config path to read `project_id` +- `-i, --id ` — project ID directly (without config) + +#### Delete project +```bash +aevs project remove -r, --id +``` + +**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 +aevs project read --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 ] +``` + +**Flags:** +- `-c, --config ` — 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 ] [-p, --project ] +``` + +**Flags:** +- `-v, --version ` — **required**, space-separated list of version timestamps +- `-n, --name ` — new version name (default: current timestamp) +- `-c, --config ` — config path to read `project_id` +- `-p, --project ` — 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 +) +``` diff --git a/.docs/01-opus-docs/02-q.md b/.docs/01-opus-docs/02-q.md new file mode 100644 index 0000000..755f075 --- /dev/null +++ b/.docs/01-opus-docs/02-q.md @@ -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: оба создают новые версии; diff --git a/.docs/01-opus-docs/03-q.md b/.docs/01-opus-docs/03-q.md new file mode 100644 index 0000000..1348f58 --- /dev/null +++ b/.docs/01-opus-docs/03-q.md @@ -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 хранилище проекты для другого ключа, чтобы на тестах убедиться, что пользователь его не видит) diff --git a/.docs/01-opus-docs/04-q.md b/.docs/01-opus-docs/04-q.md new file mode 100644 index 0000000..d692ca4 --- /dev/null +++ b/.docs/01-opus-docs/04-q.md @@ -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: ` + - (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` заново diff --git a/.docs/02-opus-cli-docs/01-overview.md b/.docs/02-opus-cli-docs/01-overview.md new file mode 100644 index 0000000..69ec310 --- /dev/null +++ b/.docs/02-opus-cli-docs/01-overview.md @@ -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 +) +``` diff --git a/.docs/02-opus-cli-docs/02-configuration.md b/.docs/02-opus-cli-docs/02-configuration.md new file mode 100644 index 0000000..3f652f5 --- /dev/null +++ b/.docs/02-opus-cli-docs/02-configuration.md @@ -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` diff --git a/.docs/02-opus-cli-docs/03-commands.md b/.docs/02-opus-cli-docs/03-commands.md new file mode 100644 index 0000000..68b17af --- /dev/null +++ b/.docs/02-opus-cli-docs/03-commands.md @@ -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 ` | +| Проект не найден в 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.` | diff --git a/.docs/02-opus-cli-docs/04-types.md b/.docs/02-opus-cli-docs/04-types.md new file mode 100644 index 0000000..17c52cc --- /dev/null +++ b/.docs/02-opus-cli-docs/04-types.md @@ -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") +) +``` diff --git a/.docs/02-opus-cli-docs/05-scenarios.md b/.docs/02-opus-cli-docs/05-scenarios.md new file mode 100644 index 0000000..9a98d00 --- /dev/null +++ b/.docs/02-opus-cli-docs/05-scenarios.md @@ -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 ` в будущем. diff --git a/.docs/02-opus-cli-docs/06-errors.md b/.docs/02-opus-cli-docs/06-errors.md new file mode 100644 index 0000000..a63b897 --- /dev/null +++ b/.docs/02-opus-cli-docs/06-errors.md @@ -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 всё будет загружено заново (идемпотентность). diff --git a/.docs/02-opus-cli-docs/README.md b/.docs/02-opus-cli-docs/README.md new file mode 100644 index 0000000..2b22957 --- /dev/null +++ b/.docs/02-opus-cli-docs/README.md @@ -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) diff --git a/.docs/03-opus-plans/implementation-plan.md b/.docs/03-opus-plans/implementation-plan.md new file mode 100644 index 0000000..2ed1b09 --- /dev/null +++ b/.docs/03-opus-plans/implementation-plan.md @@ -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) — обработка ошибок diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f460ef3 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..f998a24 --- /dev/null +++ b/Makefile @@ -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" diff --git a/README.md b/README.md new file mode 100644 index 0000000..4e89970 --- /dev/null +++ b/README.md @@ -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. diff --git a/cmd/aevs/main.go b/cmd/aevs/main.go new file mode 100644 index 0000000..e94faaa --- /dev/null +++ b/cmd/aevs/main.go @@ -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)) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..f2f10a2 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d760e03 --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/archiver/archiver.go b/internal/archiver/archiver.go new file mode 100644 index 0000000..7971267 --- /dev/null +++ b/internal/archiver/archiver.go @@ -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 +} diff --git a/internal/cli/config.go b/internal/cli/config.go new file mode 100644 index 0000000..4ce48a9 --- /dev/null +++ b/internal/cli/config.go @@ -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 +} diff --git a/internal/cli/init.go b/internal/cli/init.go new file mode 100644 index 0000000..58be821 --- /dev/null +++ b/internal/cli/init.go @@ -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 +} diff --git a/internal/cli/list.go b/internal/cli/list.go new file mode 100644 index 0000000..0e0cf6b --- /dev/null +++ b/internal/cli/list.go @@ -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]) +} diff --git a/internal/cli/pull.go b/internal/cli/pull.go new file mode 100644 index 0000000..be2e000 --- /dev/null +++ b/internal/cli/pull.go @@ -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 ") + } + 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 +} diff --git a/internal/cli/push.go b/internal/cli/push.go new file mode 100644 index 0000000..2843d81 --- /dev/null +++ b/internal/cli/push.go @@ -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 +} diff --git a/internal/cli/root.go b/internal/cli/root.go new file mode 100644 index 0000000..0af05ae --- /dev/null +++ b/internal/cli/root.go @@ -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) +} diff --git a/internal/cli/status.go b/internal/cli/status.go new file mode 100644 index 0000000..8e79f95 --- /dev/null +++ b/internal/cli/status.go @@ -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) + } +} diff --git a/internal/config/constants.go b/internal/config/constants.go new file mode 100644 index 0000000..ae77c39 --- /dev/null +++ b/internal/config/constants.go @@ -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" +) diff --git a/internal/config/global.go b/internal/config/global.go new file mode 100644 index 0000000..ded438d --- /dev/null +++ b/internal/config/global.go @@ -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 +} diff --git a/internal/config/project.go b/internal/config/project.go new file mode 100644 index 0000000..375bb40 --- /dev/null +++ b/internal/config/project.go @@ -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 +} diff --git a/internal/errors/errors.go b/internal/errors/errors.go new file mode 100644 index 0000000..033735a --- /dev/null +++ b/internal/errors/errors.go @@ -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 + } +} diff --git a/internal/scanner/scanner.go b/internal/scanner/scanner.go new file mode 100644 index 0000000..3627e64 --- /dev/null +++ b/internal/scanner/scanner.go @@ -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 +} diff --git a/internal/storage/s3.go b/internal/storage/s3.go new file mode 100644 index 0000000..519774a --- /dev/null +++ b/internal/storage/s3.go @@ -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 +} diff --git a/internal/storage/storage.go b/internal/storage/storage.go new file mode 100644 index 0000000..35ced2c --- /dev/null +++ b/internal/storage/storage.go @@ -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 +} diff --git a/internal/types/types.go b/internal/types/types.go new file mode 100644 index 0000000..a54b902 --- /dev/null +++ b/internal/types/types.go @@ -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 +)