diff --git a/.changeset/sync-policies-cli.md b/.changeset/sync-policies-cli.md new file mode 100644 index 00000000..e22335e6 --- /dev/null +++ b/.changeset/sync-policies-cli.md @@ -0,0 +1,6 @@ +--- +"@shelve/cli": minor +"@shelve/app": minor +--- + +Add sync policies for push/pull conflict handling, `shelve diff` and `shelve sync`, server-side protected environments on projects, and consolidate published agent skills into a single comprehensive `shelve` skill (remove `shelve-app`). diff --git a/AGENTS.md b/AGENTS.md index 8022fc7f..003be64d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,4 +17,4 @@ More details (progressive disclosure): - [Build env and outputs](docs/agents/build-env.md) - [Reference docs](docs/agents/docs-links.md) - [AI collaboration rules](docs/agents/ai-workflow.md) -- [Shelve CLI for agents & automation](docs/agents/cli.md) — install skill: `npx skills add https://shelve.cloud` +- [Shelve CLI for agents & automation](docs/agents/cli.md) — install the Shelve skill: `npx skills add https://shelve.cloud` diff --git a/apps/lp/content/docs/3.cli/10.agents-automation.md b/apps/lp/content/docs/3.cli/10.agents-automation.md index 3ab58505..a5fe6e0f 100644 --- a/apps/lp/content/docs/3.cli/10.agents-automation.md +++ b/apps/lp/content/docs/3.cli/10.agents-automation.md @@ -112,13 +112,13 @@ shelve --debug run --env preview -- pnpm build ## Install the agent skill -Shelve publishes [Agent Skills](https://docus.dev/en/ai/skills) at `/.well-known/skills/` on [shelve.cloud](https://shelve.cloud) (`shelve` + `shelve-app`): +Shelve publishes a single [Agent Skill](https://docus.dev/en/ai/skills) at `/.well-known/skills/` on [shelve.cloud](https://shelve.cloud) (`shelve` — CLI, platform, and sync policies): ```bash [terminal] npx skills add https://shelve.cloud ``` -The skill teaches agents the correct Shelve workflows, flags, and security rules (prefer `run`, avoid disk writes, use `SHELVE_*` env vars). +The skill teaches agents Shelve end-to-end: platform model (teams, tokens, UI), CLI workflows, sync policies, and security rules (prefer `run`, avoid disk writes, use `SHELVE_*` env vars). Reference files ship alongside `SKILL.md` (`cli-commands.md`, `platform.md`, `sync-policies.md`, `agent-workflows.md`). ## Local testing (contributors) diff --git a/apps/lp/content/docs/3.cli/11.troubleshooting.md b/apps/lp/content/docs/3.cli/11.troubleshooting.md index 50ef507a..903f64d7 100644 --- a/apps/lp/content/docs/3.cli/11.troubleshooting.md +++ b/apps/lp/content/docs/3.cli/11.troubleshooting.md @@ -22,6 +22,10 @@ shelve --json doctor | `FETCH_FAILED` | Network/API failure, no cache | Go online once, or use `--offline` if cache exists | | `FORBIDDEN` | Token lacks scope | Create a token with read/write for the team/project | | `PROJECT_NOT_FOUND` | Project missing | Enable `autoCreateProject` or `shelve create` | +| `PUSH_BLOCKED` | Push disabled for this env | Check `sync.protectedEnvironments` or `allowPush` | +| `PULL_BLOCKED` | Pull disabled for this env | Set `sync.environments..allowPull` | +| `SYNC_CONFLICT` | Diverging keys and `onPushConflict: fail` | Run `shelve diff`, align values, or change policy | +| `ENV_PROTECTED` | Server blocked write to protected env | Update project sync policy in Shelve settings | See also `shelve --help` for the full list of structured error codes. @@ -122,4 +126,4 @@ Install the published skills: npx skills add https://shelve.cloud ``` -Catalog: `https://shelve.cloud/.well-known/skills/index.json` (skills: `shelve`, `shelve-app`). +Catalog: `https://shelve.cloud/.well-known/skills/index.json` (skill: `shelve`). diff --git a/apps/lp/content/docs/3.cli/12.sync-policies.md b/apps/lp/content/docs/3.cli/12.sync-policies.md new file mode 100644 index 00000000..fc32e7b2 --- /dev/null +++ b/apps/lp/content/docs/3.cli/12.sync-policies.md @@ -0,0 +1,103 @@ +--- +title: Sync policies +description: Control who wins when local .env and Shelve diverge — push guards, pull merge, and diff. +--- + +Sync policies live under `sync` in `shelve.json` (and can be enforced on the server per project). They answer: **should a push from my laptop overwrite production?** **Does pull replace my whole `.env` or merge?** + +## Quick example + +```json [shelve.json] +{ + "$schema": "https://shelve.cloud/schema.json", + "slug": "my-team", + "project": "my-app", + "defaultEnv": "development", + "sync": { + "protectedEnvironments": ["production", "preview"], + "default": { + "onPushConflict": "overwrite", + "pullMode": "replace" + }, + "environments": { + "development": { + "sourceOfTruth": "local", + "onPushConflict": "overwrite" + }, + "production": { + "sourceOfTruth": "remote", + "allowPush": false, + "pullMode": "merge" + } + } + } +} +``` + +## Policy fields + +| Field | Values | Effect | +|-------|--------|--------| +| `sourceOfTruth` | `remote` \| `local` | Hint for [`shelve sync`](/docs/cli/sync-policies#sync-command): pull when `remote`, push when `local`. | +| `onPushConflict` | `overwrite` \| `skip` \| `fail` \| `prompt` | When a key exists on Shelve with a **different** value than local. Default: `overwrite`. | +| `pullMode` | `replace` \| `merge` | `replace`: rewrite `.env` (legacy). `merge`: remote keys win; **local-only** keys are kept. | +| `allowPush` / `allowPull` | `boolean` | Hard block with `PUSH_BLOCKED` / `PULL_BLOCKED`. | +| `protectedEnvironments` | `string[]` | Sets `allowPush: false` for listed env names. | +| `requireConfirmation` | `boolean` | Extra confirmation even if `confirmChanges` is false. | + +Per-environment overrides go in `sync.environments.`. Defaults apply via `sync.default`. + +## Server policies + +Project **Settings → Sync policy** stores `syncPolicy` on the project. Server rules **cannot be relaxed** from `shelve.json`: if the server sets `allowPush: false`, the CLI cannot override it. + +Protected environments reject API writes with `ENV_PROTECTED`. + +## Commands + +### `shelve diff` + +Compare local `envFileName` with Shelve (no writes). Safe for agents with `--json` (no secret values). + +```bash [terminal] +shelve diff --env staging +shelve --json diff --env staging +shelve diff --env staging --show-values +``` + +### `shelve sync` {#sync-command} + +Apply the effective policy for the environment: + +- `sourceOfTruth: remote` → pull (respects `pullMode`) +- `sourceOfTruth: local` → push (respects `onPushConflict`) + +```bash [terminal] +shelve sync --env development +shelve sync --env production --dry-run +shelve sync --yes --env staging +``` + +`--dry-run` reports the planned action and diff without writing. + +## Environment variables + +| Variable | Effect | +|----------|--------| +| `SHELVE_SYNC_ALLOW_PUSH=0` | Disables push for all environments in this process | +| `SHELVE_SYNC_ALLOW_PULL=0` | Disables pull for all environments | + +## Error codes + +| Code | Meaning | +|------|---------| +| `PUSH_BLOCKED` | `allowPush: false` or protected environment | +| `PULL_BLOCKED` | `allowPull: false` | +| `SYNC_CONFLICT` | `onPushConflict: fail` (or `prompt` in non-interactive mode) | +| `ENV_PROTECTED` | Server rejected a write to a protected environment | + +See [Troubleshooting](/docs/cli/troubleshooting). + +## Monorepos + +Put `protectedEnvironments` in the **root** `shelve.json`; package-level files can override `sync.environments` for each app. diff --git a/apps/lp/content/docs/3.cli/2.index.md b/apps/lp/content/docs/3.cli/2.index.md index c6fac9e5..86b9dcb7 100644 --- a/apps/lp/content/docs/3.cli/2.index.md +++ b/apps/lp/content/docs/3.cli/2.index.md @@ -28,6 +28,7 @@ pnpm add -D @shelve/cli | [`login`](/docs/cli/login-logout) / [`logout`](/docs/cli/login-logout) | Manage stored credentials | | [`me`](/docs/cli/login-logout) | Show the logged-in user | | [`push`](/docs/cli/push-pull) / [`pull`](/docs/cli/push-pull) | Sync secrets with Shelve | +| [`diff`](/docs/cli/sync-policies) / [`sync`](/docs/cli/sync-policies) | Compare or apply sync policy | | [`create`](/docs/cli/create) | Create a project + `shelve.json` | | [`config`](/docs/cli/config) | Show merged configuration | | [`generate`](/docs/cli/generate) | Generate `.env.example` or ESLint config | @@ -98,6 +99,7 @@ Commands always run in the **current directory** — they do not automatically e | `envFileName` | `string` | `.env` | Local env file name | | `autoUppercase` | `boolean` | `true` | Uppercase keys on push | | `autoCreateProject` | `boolean` | `true` | Create project if missing | +| `sync` | `object` | — | [Sync policies](/docs/cli/sync-policies) (per-env push/pull rules) | ## Environment variables diff --git a/apps/lp/content/docs/3.cli/5.push-pull.md b/apps/lp/content/docs/3.cli/5.push-pull.md index 79b4c989..53f931b5 100644 --- a/apps/lp/content/docs/3.cli/5.push-pull.md +++ b/apps/lp/content/docs/3.cli/5.push-pull.md @@ -84,3 +84,13 @@ Set `defaultEnv` to skip passing `--env` every time: ## confirmChanges When `confirmChanges: true` in `shelve.json`, push and pull ask before writing. Skip with `--yes` or global `--yes` / `--non-interactive` automation flags. + +## Sync policies + +Configure **who wins** when local and Shelve differ, block pushes to production, or merge on pull. See [Sync policies](/docs/cli/sync-policies). + +```bash [terminal] +shelve diff --env staging +shelve push --env staging +shelve sync --dry-run --env production +``` diff --git a/apps/lp/content/docs/3.cli/7.config.md b/apps/lp/content/docs/3.cli/7.config.md index cbeed1e0..37356952 100644 --- a/apps/lp/content/docs/3.cli/7.config.md +++ b/apps/lp/content/docs/3.cli/7.config.md @@ -46,7 +46,8 @@ The token is always **redacted** as `"***"` in JSON mode. - Resolved `project`, `slug`, `url`, `defaultEnv` - Credential metadata (`username`, `email`) when logged in - Monorepo detection (`isMonoRepo`, `workspaceDir`, `monorepo.paths`) -- Effective flags (`confirmChanges`, `envFileName`, …) +- Effective flags (`confirmChanges`, `envFileName`, `sync`, …) +- Resolved sync policy per environment when `sync` is configured (see [Sync policies](/docs/cli/sync-policies)) If required fields are missing and you are in non-interactive mode, the command fails with `MISSING_SLUG`, `MISSING_PROJECT`, or `AUTH_REQUIRED` instead of prompting. diff --git a/apps/lp/nuxt.config.ts b/apps/lp/nuxt.config.ts index f24beb96..6d06ddc5 100644 --- a/apps/lp/nuxt.config.ts +++ b/apps/lp/nuxt.config.ts @@ -57,15 +57,10 @@ export default defineNuxtConfig({ href: 'https://shelve.cloud/.well-known/skills/index.json', }, { - title: 'CLI skill (shelve)', - description: 'Shelve CLI workflows, flags, and security rules', + title: 'Shelve agent skill', + description: 'CLI, platform, sync policies, tokens, and agent workflows', href: '/.well-known/skills/shelve/SKILL.md', }, - { - title: 'App skill (shelve-app)', - description: 'Shelve platform, tokens, teams, and audit logs', - href: '/.well-known/skills/shelve-app/SKILL.md', - }, { title: 'CLI agents & automation', description: 'Global flags, JSON output, CI patterns', diff --git a/apps/lp/skills/shelve/SKILL.md b/apps/lp/skills/shelve/SKILL.md index 7d05ced1..70b20ad9 100644 --- a/apps/lp/skills/shelve/SKILL.md +++ b/apps/lp/skills/shelve/SKILL.md @@ -1,104 +1,95 @@ --- name: shelve -description: Manage secrets with Shelve and @shelve/cli — inject env vars with shelve run, sync with push/pull, secure AI workspaces with init, and use --json/--non-interactive for automation. +description: Complete guide to Shelve — team secrets platform, CLI (@shelve/cli), sync policies, scoped tokens, shelve run for agents/CI, push/pull/diff, and the web app. Install with npx skills add https://shelve.cloud --- -# Shelve CLI & secrets +# Shelve -Shelve is a team secrets platform. The **`@shelve/cli`** package (`shelve` binary) syncs environment variables between your machine and Shelve. +Shelve is an open-source **team secrets platform**: web app + CLI. Store variables per team, project, and environment; inject at runtime without committing `.env` files. -Official docs: https://shelve.cloud/docs/cli -Install skill updates: `npx skills add https://shelve.cloud` +- **Docs:** https://shelve.cloud/docs +- **CLI docs:** https://shelve.cloud/docs/cli +- **Install / update this skill:** `npx skills add https://shelve.cloud` +- **Catalog:** https://shelve.cloud/.well-known/skills/index.json ## Security rules (read first) -1. **Prefer `shelve run -- `** — secrets stay in the child process environment; nothing is written to `.env` on disk. -2. **Avoid `shelve pull` in agent shells** — it writes plaintext secrets to disk. Agents can exfiltrate them. If you must pull, pass `--yes` explicitly and ensure `.cursorignore` / agent ignores exist (`shelve init`). -3. **Never commit** `SHELVE_TOKEN`, `.env`, or cache files under `~/.shelve/`. -4. **Never print secret values** in logs, JSON, or commit messages. CLI `--json` output excludes values by design. -5. Run **`shelve init`** once per workspace before any secret operations. +1. **Prefer `shelve run -- `** — secrets stay in the child process; no `.env` on disk. +2. **Avoid `shelve pull` in agent shells** — writes plaintext secrets agents can read. Use `--yes` only if the user explicitly needs a disk file; run `shelve init` first. +3. **Never commit** `SHELVE_TOKEN`, `.env`, or `~/.shelve/` cache. +4. **Never print secret values** in logs, JSON, or commits. CLI `--json` excludes values by design. +5. Run **`shelve init`** once per workspace before secret operations. +6. **Protect production:** use `sync.protectedEnvironments` in `shelve.json` and/or project Settings → Sync policy. -## Non-interactive setup +## Platform (teams, tokens, UI) -Run **`shelve doctor --json`** first in automation to fail fast. +| Concept | CLI / config | +|---------|----------------| +| Team | `slug` in `shelve.json`, `SHELVE_TEAM_SLUG` | +| Project | `project`, `SHELVE_PROJECT` | +| Environment | `--env`, `defaultEnv`, `SHELVE_DEFAULT_ENV` | +| API token | `SHELVE_TOKEN` — create at https://app.shelve.cloud/user/tokens (shown once; scope read/write + team/project/env) | -Set these environment variables so the CLI never prompts: +**CLI vs UI:** bulk edit and audit logs → UI; local dev and CI → CLI `run`. Details: **`platform.md`**. + +## Non-interactive / agents + +Run **`shelve doctor --json`** first in automation. | Variable | Purpose | |----------|---------| -| `SHELVE_TOKEN` | API token from app.shelve.cloud/user/tokens | +| `SHELVE_TOKEN` | API token | | `SHELVE_TEAM_SLUG` | Team slug | | `SHELVE_PROJECT` | Project name | -| `SHELVE_DEFAULT_ENV` | Default environment (optional) | -| `SHELVE_URL` | Instance URL (default `https://app.shelve.cloud`) | - -Global flags (usable before or after the subcommand): +| `SHELVE_DEFAULT_ENV` | Default environment | +| `SHELVE_URL` | Instance (default `https://app.shelve.cloud`) | | Flag | Effect | |------|--------| -| `--json` | Machine-readable stdout; errors as JSON on stderr | -| `--quiet` / `-q` | No spinners or clack UI | +| `--json` | Machine-readable stdout; JSON errors on stderr | +| `--quiet` / `-q` | No spinners | | `--yes` / `-y` | Skip confirmations | | `--non-interactive` | Fail instead of prompt | -| `--debug` | Verbose logs (`SHELVE_DEBUG=1` also works) | +| `--debug` | Verbose (`SHELVE_DEBUG=1`) | -Auto non-interactive when: `CI=true`, AI agent shell detected, or `AI_AGENT` is set. +Auto non-interactive when `CI=true`, agent shell detected, or `AI_AGENT` is set. ## Command cheat sheet ```bash -# Inject secrets and run (preferred) +# Preferred: inject secrets shelve run -- pnpm dev shelve run --env preview -- pnpm build -shelve run dev # resolves package.json script directly - -# One-time workspace hardening -shelve init +shelve run dev # package.json script shortcut -# Auth +shelve init # agent ignores + .gitignore block shelve login --token "$SHELVE_TOKEN" -shelve me --json -shelve logout +shelve doctor --json +shelve --json config -# Sync (human workflows; use --env) +# Sync +shelve diff --env staging shelve push --env development --yes -shelve pull --env development --yes # dangerous in agent shells without --yes - -# Inspect / validate -shelve doctor --json -shelve --json config # token shown as *** +shelve pull --env development --yes # risky in agent shells +shelve sync --dry-run --env production -# Scaffold shelve create --name my-app --slug my-team shelve generate --type env-example -shelve generate --type eslint ``` ## `shelve run` flags | Flag | Purpose | |------|---------| -| `--env` | Environment name | -| `--template` | Path to `.env.template` with `shelve://` references | -| `--offline` | Use encrypted cache only (no API) | -| `--no-cache` | Disable cache read/write | -| `--cache-ttl` | Cache freshness (`15m`, `2h`, `1d`; default 24h) | -| `--watch` | Poll Shelve; reload child on change | -| `--restart-on-change` | With `--watch`, respawn child instead of SIGHUP | +| `--env` | Environment | +| `--template` | `.env.template` with `shelve://` refs | +| `--offline` | Encrypted cache only | +| `--no-cache` | Disable cache | +| `--cache-ttl` | e.g. `15m`, `24h` default | +| `--watch` | Reload on remote changes | +| `--restart-on-change` | Respawn child instead of SIGHUP | -## Error codes (JSON / automation) - -| Code | Meaning | -|------|---------| -| `AGENT_BLOCKED` | `pull` refused in agent shell without `--yes` | -| `AUTH_REQUIRED` | Missing token; set `SHELVE_TOKEN` or `login --token` | -| `MISSING_ENV` | Pass `--env` or set `defaultEnv` / `SHELVE_DEFAULT_ENV` | -| `MISSING_INPUT` | Required flag missing in non-interactive mode | -| `FETCH_FAILED` | `run` could not fetch secrets and cache unavailable | - -## Configuration file - -`shelve.json` in the project root (merged with monorepo root config): +## Sync policies (`shelve.json`) ```json { @@ -106,21 +97,44 @@ shelve generate --type eslint "slug": "my-team", "project": "my-app", "defaultEnv": "development", - "confirmChanges": false, - "autoCreateProject": true + "sync": { + "protectedEnvironments": ["production"], + "environments": { + "development": { "sourceOfTruth": "local" }, + "production": { "sourceOfTruth": "remote", "allowPush": false, "pullMode": "merge" } + } + } } ``` -## When to read reference files +See **`sync-policies.md`** and https://shelve.cloud/docs/cli/sync-policies -- **`cli-commands.md`** — full command list, JSON shapes, exit codes -- **`agent-workflows.md`** — CI, monorepo, and AI agent patterns +## Error codes + +| Code | Meaning | +|------|---------| +| `AGENT_BLOCKED` | `pull` in agent shell without `--yes` | +| `AUTH_REQUIRED` | Missing token | +| `MISSING_ENV` | No `--env` / `defaultEnv` | +| `FETCH_FAILED` | API/cache failure in `run` | +| `PUSH_BLOCKED` / `PULL_BLOCKED` | Sync policy | +| `SYNC_CONFLICT` | `onPushConflict: fail` or prompt in CI | +| `ENV_PROTECTED` | Server blocked push to protected env | + +## Reference files (read when needed) + +| File | Contents | +|------|----------| +| **`cli-commands.md`** | All commands, JSON shapes, config keys | +| **`agent-workflows.md`** | CI, GitHub Actions, monorepo, templates, watch | +| **`platform.md`** | Teams, tokens, encryption, UI flows | +| **`sync-policies.md`** | Push/pull conflict rules, `diff` / `sync` | ## Common mistakes | Mistake | Fix | |---------|-----| -| `shelve pull` in Cursor/Claude | Use `shelve run -- ` | -| CLI hangs waiting for input | Set `SHELVE_*` env vars + `--non-interactive` | -| `shelve run dev` exits instantly | Use `shelve run -- pnpm dev` or `--debug` | -| Secrets in git | Run `shelve init`; never commit `.env` | +| `shelve pull` in Cursor/Claude | `shelve run -- ` | +| CLI hangs | `SHELVE_*` + `--non-interactive` | +| Push overwrites prod | `protectedEnvironments` + `shelve diff` first | +| Secrets in git | `shelve init`; never commit `.env` | diff --git a/apps/lp/skills/shelve/agent-workflows.md b/apps/lp/skills/shelve/agent-workflows.md index 8f2b568c..86f492d9 100644 --- a/apps/lp/skills/shelve/agent-workflows.md +++ b/apps/lp/skills/shelve/agent-workflows.md @@ -92,4 +92,4 @@ shelve run --watch --restart-on-change -- pnpm dev npx skills add https://shelve.cloud ``` -Catalog: `https://shelve.cloud/.well-known/skills/index.json` +Catalog: `https://shelve.cloud/.well-known/skills/index.json` (single skill: `shelve`) diff --git a/apps/lp/skills/shelve/cli-commands.md b/apps/lp/skills/shelve/cli-commands.md index a02c7868..9b6bb6ae 100644 --- a/apps/lp/skills/shelve/cli-commands.md +++ b/apps/lp/skills/shelve/cli-commands.md @@ -12,6 +12,8 @@ | `me` | Show logged-in user | | `push` | Upload local `.env` variables to Shelve | | `pull` | Download variables to local `.env` file | +| `diff` | Compare local `.env` with Shelve (no writes) | +| `sync` | Apply `sourceOfTruth` policy (push or pull) | | `create` | Create Shelve project + `shelve.json` | | `config` | Print merged configuration | | `generate` | Generate `.env.example` or ESLint config | @@ -119,6 +121,35 @@ Types: `env-example`, `eslint` (via `--type`). | 129 | Parent gone / EIO | | 130 / 143 | SIGINT / SIGTERM | +### `diff` + +```json +{ + "env": "development", + "file": ".env", + "policy": { "sourceOfTruth": "remote", "onPushConflict": "overwrite", "pullMode": "replace", "allowPush": true, "allowPull": true, "requireConfirmation": false }, + "onlyLocal": ["LOCAL_KEY"], + "onlyRemote": [], + "changed": ["API_URL"], + "unchanged": ["NODE_ENV"] +} +``` + +### `sync` + +```json +{ "env": "development", "action": "pull", "variableCount": 12, "file": ".env" } +``` + +## Error codes (automation) + +| Code | Meaning | +|------|---------| +| `PUSH_BLOCKED` | Push disabled by sync policy | +| `PULL_BLOCKED` | Pull disabled by sync policy | +| `SYNC_CONFLICT` | Diverging keys with `onPushConflict: fail` | +| `ENV_PROTECTED` | Server blocked write to protected environment | + ## Environment variables | Variable | Overrides | @@ -142,5 +173,6 @@ Types: `env-example`, `eslint` (via `--type`). | `envFileName` | `.env` | Local env file name | | `autoUppercase` | `true` | Uppercase keys on push | | `autoCreateProject` | `true` | Create project if missing | +| `sync` | — | Per-env sync policy (see `sync-policies.md` in skill folder) | Schema: https://shelve.cloud/schema.json diff --git a/apps/lp/skills/shelve-app/SKILL.md b/apps/lp/skills/shelve/platform.md similarity index 62% rename from apps/lp/skills/shelve-app/SKILL.md rename to apps/lp/skills/shelve/platform.md index b469c691..4b5f2341 100644 --- a/apps/lp/skills/shelve-app/SKILL.md +++ b/apps/lp/skills/shelve/platform.md @@ -1,14 +1,6 @@ ---- -name: shelve-app -description: Use the Shelve web app and API concepts — teams, projects, environments, scoped tokens, audit logs, and encryption — when helping users manage secrets outside the CLI. ---- +# Shelve platform (app & API) -# Shelve app & platform - -Shelve is a team secrets platform at [shelve.cloud](https://shelve.cloud). The **CLI** (`shelve` skill) syncs secrets locally; this skill covers the **dashboard and API model**. - -Official docs: https://shelve.cloud/docs -CLI skill: install with `npx skills add https://shelve.cloud` (skill `shelve`) +Official docs: https://shelve.cloud/docs ## Core concepts @@ -32,10 +24,11 @@ Create at [app.shelve.cloud/user/tokens](https://app.shelve.cloud/user/tokens): ## Security model -- Variables encrypted at rest (see [Encryption](/docs/core-features/encryption)) -- [Audit logs](/docs/core-features/audit-logs) record token usage and changes +- Variables encrypted at rest — https://shelve.cloud/docs/core-features/encryption +- Audit logs record token usage and changes — https://shelve.cloud/docs/core-features/audit-logs - Prefer **CLI `shelve run`** for agents — avoids writing `.env` to disk - Run **`shelve init`** in repos so agents ignore secret files +- **Project sync policy** (Settings → Sync policy): `protectedEnvironments` blocks API/CLI push server-side ## Typical user flows @@ -50,7 +43,7 @@ Create at [app.shelve.cloud/user/tokens](https://app.shelve.cloud/user/tokens): ### CI pipeline 1. Store `SHELVE_TOKEN` in CI secrets (scoped to `ci` environment if possible) -2. Use [GitHub Action `shelve-run`](https://github.com/HugoRCD/shelve/tree/main/.github/actions/shelve-run) or `npx @shelve/cli run -- …` +2. Use GitHub Action `shelve-run` or `npx @shelve/cli run -- …` 3. Run `shelve doctor --json` in a setup step to fail fast ### Rotating a compromised secret @@ -58,7 +51,7 @@ Create at [app.shelve.cloud/user/tokens](https://app.shelve.cloud/user/tokens): 1. Update variable in Shelve UI (or `shelve push` from trusted machine) 2. Restart apps / CI — `shelve run --watch` picks up changes for long-running dev servers -## When to use CLI vs UI +## CLI vs UI | Task | Tool | |------|------| @@ -67,11 +60,12 @@ Create at [app.shelve.cloud/user/tokens](https://app.shelve.cloud/user/tokens): | Create scoped CI token | Shelve UI | | Audit who changed what | Shelve UI audit logs | | Agent-safe workspace setup | CLI `shelve init` | +| Block push to production | UI sync policy + `shelve.json` `sync` | -## Related documentation +## Documentation links -- [Environments](/docs/core-features/environments) -- [API Tokens](/docs/core-features/tokens) -- [Audit logs](/docs/core-features/audit-logs) -- [CLI agents & automation](/docs/cli/agents-automation) -- [CLI troubleshooting](/docs/cli/troubleshooting) +- Environments — https://shelve.cloud/docs/core-features/environments +- API Tokens — https://shelve.cloud/docs/core-features/tokens +- Audit logs — https://shelve.cloud/docs/core-features/audit-logs +- CLI agents & automation — https://shelve.cloud/docs/cli/agents-automation +- Sync policies — https://shelve.cloud/docs/cli/sync-policies diff --git a/apps/lp/skills/shelve/sync-policies.md b/apps/lp/skills/shelve/sync-policies.md new file mode 100644 index 00000000..a4ceb837 --- /dev/null +++ b/apps/lp/skills/shelve/sync-policies.md @@ -0,0 +1,45 @@ +# Sync policies (CLI) + +Full docs: https://shelve.cloud/docs/cli/sync-policies + +Control **who wins** when local `.env` and Shelve diverge. + +## `shelve.json` example + +```json +{ + "sync": { + "protectedEnvironments": ["production", "preview"], + "default": { "onPushConflict": "overwrite", "pullMode": "replace" }, + "environments": { + "development": { "sourceOfTruth": "local", "onPushConflict": "overwrite" }, + "production": { "sourceOfTruth": "remote", "allowPush": false, "pullMode": "merge" } + } + } +} +``` + +## Fields + +| Field | Values | Effect | +|-------|--------|--------| +| `sourceOfTruth` | `remote` \| `local` | `shelve sync`: pull vs push | +| `onPushConflict` | `overwrite` \| `skip` \| `fail` \| `prompt` | When remote value differs from local | +| `pullMode` | `replace` \| `merge` | `merge` keeps local-only keys | +| `allowPush` / `allowPull` | `boolean` | Hard block → `PUSH_BLOCKED` / `PULL_BLOCKED` | +| `protectedEnvironments` | `string[]` | Sets `allowPush: false` for those envs | + +Server project policy **cannot be relaxed** from local config (stricter `allowPush: false` wins). + +## Commands + +```bash +shelve diff --env staging +shelve push --env staging +shelve sync --dry-run --env production +``` + +## Env overrides + +- `SHELVE_SYNC_ALLOW_PUSH=0` — disable all pushes +- `SHELVE_SYNC_ALLOW_PULL=0` — disable all pulls diff --git a/apps/shelve/app/pages/[teamSlug]/projects/[projectId]/index/settings.vue b/apps/shelve/app/pages/[teamSlug]/projects/[projectId]/index/settings.vue index 8a5c78a2..f145b285 100644 --- a/apps/shelve/app/pages/[teamSlug]/projects/[projectId]/index/settings.vue +++ b/apps/shelve/app/pages/[teamSlug]/projects/[projectId]/index/settings.vue @@ -13,6 +13,25 @@ const currentLoading = useCurrentLoading() const teamRole = useTeamRole() const canUpdate = computed(() => hasAccess(teamRole.value, TeamRole.ADMIN)) +const protectedEnvironmentsTextInput = ref('') + +watch( + () => project.value?.syncPolicy?.protectedEnvironments, + (names) => { + protectedEnvironmentsTextInput.value = (names ?? []).join(', ') + }, + { immediate: true }, +) + +function applyProtectedEnvironmentsFromInput() { + if (!project.value) return + const names = protectedEnvironmentsTextInput.value.split(',').map(s => s.trim()).filter(Boolean) + project.value.syncPolicy = { + ...project.value.syncPolicy, + protectedEnvironments: names.length ? names : undefined, + } +} + async function onSubmit(event: FormSubmitEvent) { await useProjectsService().updateProject(event.data) } @@ -83,6 +102,31 @@ definePageMeta({ +
+
+

+ Sync policy +

+

+ Block CLI and API pushes to sensitive environments. Mirrors sync.protectedEnvironments in shelve.json. +

+
+ + + +
+

diff --git a/apps/shelve/app/utils/zod/project.ts b/apps/shelve/app/utils/zod/project.ts index 23ffdd24..e0203a1d 100644 --- a/apps/shelve/app/utils/zod/project.ts +++ b/apps/shelve/app/utils/zod/project.ts @@ -1,4 +1,5 @@ import * as z from 'zod' +import { shelveSyncConfigSchema } from './sync-policy' const baseProjectSchema = z.object({ name: z.string().min(3, 'Name must be at least 3 characters long'), @@ -16,6 +17,7 @@ const baseProjectSchema = z.object({ message: 'Homepage must be a valid URL', }).optional(), variablePrefix: z.string().optional(), + syncPolicy: shelveSyncConfigSchema.nullable().optional(), }) export const createProjectSchema = baseProjectSchema diff --git a/apps/shelve/app/utils/zod/sync-policy.ts b/apps/shelve/app/utils/zod/sync-policy.ts new file mode 100644 index 00000000..e9690d64 --- /dev/null +++ b/apps/shelve/app/utils/zod/sync-policy.ts @@ -0,0 +1,16 @@ +import { z } from 'zod' + +export const syncPolicyFieldsSchema = z.object({ + sourceOfTruth: z.enum(['remote', 'local']).optional(), + onPushConflict: z.enum(['overwrite', 'skip', 'fail', 'prompt']).optional(), + pullMode: z.enum(['replace', 'merge']).optional(), + allowPush: z.boolean().optional(), + allowPull: z.boolean().optional(), + requireConfirmation: z.boolean().optional(), +}).strict() + +export const shelveSyncConfigSchema = z.object({ + default: syncPolicyFieldsSchema.optional(), + environments: z.record(z.string().min(1), syncPolicyFieldsSchema).optional(), + protectedEnvironments: z.array(z.string().min(1)).optional(), +}).strict() diff --git a/apps/shelve/server/api/teams/[slug]/projects/[projectId]/index.put.ts b/apps/shelve/server/api/teams/[slug]/projects/[projectId]/index.put.ts index cf266176..c39baecf 100644 --- a/apps/shelve/server/api/teams/[slug]/projects/[projectId]/index.put.ts +++ b/apps/shelve/server/api/teams/[slug]/projects/[projectId]/index.put.ts @@ -1,5 +1,8 @@ import { z } from 'zod' import { TeamRole } from '@types' +import { shelveSyncConfigSchema } from '~/utils/zod/sync-policy' + +const syncPolicySchema = shelveSyncConfigSchema.nullable().optional() const updateProjectSchema = z.object({ name: z.string().min(1).max(255).trim(), @@ -9,6 +12,7 @@ const updateProjectSchema = z.object({ variablePrefix: z.string().trim().optional(), repository: z.string().trim().optional(), logo: z.string().trim().optional(), + syncPolicy: syncPolicySchema, }) const projectIdParamsSchema = z.object({ diff --git a/apps/shelve/server/api/teams/[slug]/projects/[projectId]/variables/[variableId]/index.put.ts b/apps/shelve/server/api/teams/[slug]/projects/[projectId]/variables/[variableId]/index.put.ts index 5bf11036..4065a4ea 100644 --- a/apps/shelve/server/api/teams/[slug]/projects/[projectId]/variables/[variableId]/index.put.ts +++ b/apps/shelve/server/api/teams/[slug]/projects/[projectId]/variables/[variableId]/index.put.ts @@ -1,6 +1,6 @@ import { z } from 'zod' import { TeamRole } from '@types' -import { variableIdParamsSchema } from '~~/server/db/zod' +import { projectIdParamsSchema, variableIdParamsSchema } from '~~/server/db/zod' const updateVariableSchema = z.object({ autoUppercase: z.boolean().optional(), @@ -19,9 +19,15 @@ const updateVariableSchema = z.object({ export default eventHandler(async (event) => { const slug = await getTeamSlugFromEvent(event) - await requireUserTeam(event, slug, { minRole: TeamRole.ADMIN }) + const { team } = await requireUserTeam(event, slug, { minRole: TeamRole.ADMIN }) const { variableId } = await getValidatedRouterParams(event, variableIdParamsSchema.parse) + const { projectId } = await getValidatedRouterParams(event, projectIdParamsSchema.parse) const body = await readValidatedBody(event, updateVariableSchema.parse) + + const project = await new ProjectsService().getProject(projectId) + const environmentIds = [...new Set(body.values.map(v => v.environmentId))] + await assertPushAllowedForEnvironmentIds(environmentIds, team.id, project.syncPolicy) + await new VariablesService(event).updateVariable({ id: variableId, key: body.key, diff --git a/apps/shelve/server/api/teams/[slug]/projects/[projectId]/variables/index.post.ts b/apps/shelve/server/api/teams/[slug]/projects/[projectId]/variables/index.post.ts index 655b5190..0cd64f84 100644 --- a/apps/shelve/server/api/teams/[slug]/projects/[projectId]/variables/index.post.ts +++ b/apps/shelve/server/api/teams/[slug]/projects/[projectId]/variables/index.post.ts @@ -23,6 +23,9 @@ export default eventHandler(async (event) => { const { team } = await requireUserTeam(event, slug) const body = await readValidatedBody(event, createVariablesSchema.parse) const { projectId } = await getValidatedRouterParams(event, projectIdParamsSchema.parse) + const project = await new ProjectsService().getProject(projectId) + + await assertPushAllowedForEnvironmentIds(body.environmentIds, team.id, project.syncPolicy) for (const environmentId of body.environmentIds) { await requireTokenScope(event, { diff --git a/apps/shelve/server/db/migrations/postgresql/0007_project_sync_policy.sql b/apps/shelve/server/db/migrations/postgresql/0007_project_sync_policy.sql new file mode 100644 index 00000000..ea67bbe2 --- /dev/null +++ b/apps/shelve/server/db/migrations/postgresql/0007_project_sync_policy.sql @@ -0,0 +1 @@ +ALTER TABLE "projects" ADD COLUMN "syncPolicy" jsonb; diff --git a/apps/shelve/server/db/migrations/postgresql/meta/_journal.json b/apps/shelve/server/db/migrations/postgresql/meta/_journal.json index fcbf15b3..ff7b6070 100644 --- a/apps/shelve/server/db/migrations/postgresql/meta/_journal.json +++ b/apps/shelve/server/db/migrations/postgresql/meta/_journal.json @@ -50,6 +50,13 @@ "when": 1782700000002, "tag": "0006_project_dek", "breakpoints": true + }, + { + "idx": 7, + "version": "7", + "when": 1782700000003, + "tag": "0007_project_sync_policy", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/shelve/server/db/schema.ts b/apps/shelve/server/db/schema.ts index dd2591d7..a589c19e 100644 --- a/apps/shelve/server/db/schema.ts +++ b/apps/shelve/server/db/schema.ts @@ -1,7 +1,7 @@ import { boolean, pgEnum, pgTable, varchar, index, uniqueIndex, bigint, integer, timestamp, jsonb } from 'drizzle-orm/pg-core' import { relations } from 'drizzle-orm' import { TeamRole, Role, AuthType, InvitationStatus } from '../../../../packages/types' -import type { TokenScopes } from '../../../../packages/types' +import type { ShelveSyncConfig, TokenScopes } from '../../../../packages/types' const timestamps = { updatedAt: timestamp().notNull().$onUpdate(() => new Date()), @@ -108,6 +108,7 @@ export const projects = pgTable('projects', { variablePrefix: varchar({ length: 500 }).default('').notNull(), logo: varchar({ length: 500 }).default(DEFAULT_LOGO).notNull(), encryptedDek: varchar({ length: 1024 }), + syncPolicy: jsonb().$type(), ...timestamps, }, (table) => [ uniqueIndex('projects_team_name_idx').on(table.teamId, table.name), diff --git a/apps/shelve/server/utils/sync-policy.ts b/apps/shelve/server/utils/sync-policy.ts new file mode 100644 index 00000000..f06e75a7 --- /dev/null +++ b/apps/shelve/server/utils/sync-policy.ts @@ -0,0 +1,53 @@ +import { resolveSyncPolicy, type ShelveSyncConfig } from '@types' +import { and, eq, inArray } from 'drizzle-orm' + +export async function getEnvironmentName(environmentId: number, teamId: number): Promise { + const environment = await db.query.environments.findFirst({ + where: and( + eq(schema.environments.id, environmentId), + eq(schema.environments.teamId, teamId), + ), + }) + if (!environment) { + throw createError({ statusCode: 404, statusMessage: 'Environment not found' }) + } + return environment.name +} + +export function assertPushAllowedForEnvironment( + environmentName: string, + syncPolicy: ShelveSyncConfig | null | undefined, +): void { + const policy = resolveSyncPolicy(environmentName, syncPolicy ?? undefined) + if (!policy.allowPush) { + throw createError({ + statusCode: 403, + statusMessage: 'ENV_PROTECTED', + message: `Push to "${environmentName}" is blocked by project sync policy.`, + }) + } +} + +export async function assertPushAllowedForEnvironmentIds( + environmentIds: number[], + teamId: number, + syncPolicy: ShelveSyncConfig | null | undefined, +): Promise { + const uniqueIds = [...new Set(environmentIds)] + if (uniqueIds.length === 0) return + + const environments = await db.query.environments.findMany({ + where: and( + eq(schema.environments.teamId, teamId), + inArray(schema.environments.id, uniqueIds), + ), + }) + + if (environments.length !== uniqueIds.length) { + throw createError({ statusCode: 404, statusMessage: 'Environment not found' }) + } + + for (const environment of environments) { + assertPushAllowedForEnvironment(environment.name, syncPolicy) + } +} diff --git a/docs/agents/cli.md b/docs/agents/cli.md index 19ebda00..8ced27fb 100644 --- a/docs/agents/cli.md +++ b/docs/agents/cli.md @@ -10,7 +10,7 @@ Use this guide when driving `@shelve/cli` from scripts, CI, or AI agents inside npx skills add https://shelve.cloud ``` -Catalog: `https://shelve.cloud/.well-known/skills/index.json` +Catalog: `https://shelve.cloud/.well-known/skills/index.json` (single skill: `shelve` — CLI + platform) ## Recommended workflow @@ -101,4 +101,4 @@ See [`playground/run/README.md`](../../playground/run/README.md). ## Skill source in this repo -The published skill lives at [`apps/lp/skills/shelve/`](../../apps/lp/skills/shelve/) and is served by Docus at build time. +The published skill lives at [`apps/lp/skills/shelve/`](../../apps/lp/skills/shelve/) (`SKILL.md` + reference files) and is served by Docus at build time. There is only one skill (`shelve`), not separate CLI/app skills. diff --git a/docs/agents/docs-links.md b/docs/agents/docs-links.md index 86af894d..8b2aec55 100644 --- a/docs/agents/docs-links.md +++ b/docs/agents/docs-links.md @@ -2,7 +2,7 @@ - [Shelve CLI (public docs)](https://shelve.cloud/docs/cli) - [Shelve CLI — agents & automation](https://shelve.cloud/docs/cli/agents-automation) -- [Shelve agent skill catalog](https://shelve.cloud/.well-known/skills/index.json) — install with `npx skills add https://shelve.cloud` +- [Shelve agent skill](https://shelve.cloud/.well-known/skills/shelve/SKILL.md) — single skill (CLI + platform); install with `npx skills add https://shelve.cloud` - [NuxtHub module](https://nuxt.com/modules/hub) - [NuxtHub installation](https://hub.nuxt.com/docs/getting-started/installation) - [NuxtHub DB](https://hub.nuxt.com/docs/database) diff --git a/packages/cli/schema.json b/packages/cli/schema.json index 646f812f..4e250fd1 100644 --- a/packages/cli/schema.json +++ b/packages/cli/schema.json @@ -39,6 +39,49 @@ "type": "boolean", "description": "Whether to automatically create a project if it doesn't exist", "default": true + }, + "sync": { + "type": "object", + "description": "Sync policies for push, pull, and conflict resolution", + "properties": { + "default": { "$ref": "#/definitions/syncPolicy" }, + "environments": { + "type": "object", + "additionalProperties": { "$ref": "#/definitions/syncPolicy" } + }, + "protectedEnvironments": { + "type": "array", + "items": { "type": "string" }, + "description": "Environment names where push is blocked" + } + }, + "additionalProperties": false + } + }, + "definitions": { + "syncPolicy": { + "type": "object", + "properties": { + "sourceOfTruth": { + "type": "string", + "enum": ["remote", "local"], + "description": "Whether Shelve (remote) or the local env file is authoritative for shelve sync" + }, + "onPushConflict": { + "type": "string", + "enum": ["overwrite", "skip", "fail", "prompt"], + "description": "How to handle keys that differ between local file and Shelve on push" + }, + "pullMode": { + "type": "string", + "enum": ["replace", "merge"], + "description": "Replace the env file on pull, or merge remote keys into the existing file" + }, + "allowPush": { "type": "boolean", "description": "Allow pushing variables to this environment" }, + "allowPull": { "type": "boolean", "description": "Allow pulling variables from this environment" }, + "requireConfirmation": { "type": "boolean", "description": "Require confirmation before push or pull writes" } + }, + "additionalProperties": false } } } diff --git a/packages/cli/src/commands/diff.ts b/packages/cli/src/commands/diff.ts new file mode 100644 index 00000000..92b28f39 --- /dev/null +++ b/packages/cli/src/commands/diff.ts @@ -0,0 +1,109 @@ +import { defineCommand } from 'citty' +import { isJson, loadShelveConfig } from '../utils' +import { getResolvedSyncPolicy } from '../utils/sync-policy' +import { cliIntro, cliSuccess } from '../utils/output' +import { CliError } from '../services/api-error' +import { EnvironmentService, ProjectService, SyncService } from '../services' + +function normalizeKey(key: string, autoUppercase: boolean): string { + return autoUppercase ? key.toUpperCase() : key +} + +export default defineCommand({ + meta: { + name: 'diff', + description: 'Compare local env file with Shelve (no writes)', + }, + args: { + env: { + type: 'string', + description: 'Environment to compare against', + required: false, + }, + 'show-values': { + type: 'boolean', + description: 'Include secret values in human output (never in JSON)', + required: false, + }, + }, + async run({ args }) { + const { + project, + slug, + envFileName, + autoCreateProject, + defaultEnv, + autoUppercase, + sync, + } = await loadShelveConfig(true) + + const env = args.env || defaultEnv + if (!env) { + throw new CliError( + 'Environment name is required.', + 'MISSING_ENV', + undefined, + 'Pass --env or set defaultEnv in shelve.json / SHELVE_DEFAULT_ENV.', + ) + } + + cliIntro(`Diff local ${envFileName} vs ${env}`) + + const projectData = await ProjectService.getProjectByName(project, slug, autoCreateProject) + const environment = await EnvironmentService.getEnvironment(slug, env) + const policy = getResolvedSyncPolicy(environment.name, sync, projectData.syncPolicy) + + const syncContext = await SyncService.loadSyncContext({ + project: projectData, + environmentId: environment.id, + environmentName: environment.name, + slug, + autoUppercase, + }) + + const { diff } = syncContext + const data = { + env: environment.name, + file: envFileName, + policy, + onlyLocal: diff.onlyLocal, + onlyRemote: diff.onlyRemote, + changed: diff.changed, + unchanged: diff.unchanged, + } + + if (isJson()) { + cliSuccess(data, undefined, 'diff') + return + } + + const lines = [ + `Environment: ${environment.name}`, + `Source of truth (policy): ${policy.sourceOfTruth}`, + `onPushConflict: ${policy.onPushConflict} · pullMode: ${policy.pullMode}`, + `allowPush: ${policy.allowPush} · allowPull: ${policy.allowPull}`, + '', + `Only in ${envFileName} (${diff.onlyLocal.length}): ${diff.onlyLocal.join(', ') || '—'}`, + `Only on Shelve (${diff.onlyRemote.length}): ${diff.onlyRemote.join(', ') || '—'}`, + `Changed (${diff.changed.length}): ${diff.changed.join(', ') || '—'}`, + `Unchanged (${diff.unchanged.length}): ${diff.unchanged.length} key(s)`, + ] + + if (args['show-values'] && diff.changed.length > 0) { + lines.push('', 'Changed values (local → remote):') + const localMap = new Map( + syncContext.local.map(v => [normalizeKey(v.key, autoUppercase), v.value]), + ) + const remoteMap = new Map( + syncContext.remote.map(v => [normalizeKey(v.key, autoUppercase), v.value]), + ) + for (const key of diff.changed) { + const lookup = normalizeKey(key, autoUppercase) + lines.push(` ${key}: ${localMap.get(lookup) ?? '?'} → ${remoteMap.get(lookup) ?? '?'}`) + } + } + + console.log(lines.join('\n')) + cliSuccess(undefined, 'Diff complete') + }, +}) diff --git a/packages/cli/src/commands/pull.ts b/packages/cli/src/commands/pull.ts index 4aa8bcf5..6e765f30 100644 --- a/packages/cli/src/commands/pull.ts +++ b/packages/cli/src/commands/pull.ts @@ -1,8 +1,10 @@ import { confirm, isCancel } from '@clack/prompts' import { defineCommand } from 'citty' import { isAgentShell, loadShelveConfig, shouldSkipConfirm } from '../utils' +import { assertPullAllowed, getResolvedSyncPolicy } from '../utils/sync-policy' import { cliCancel, cliError, cliIntro, cliSuccess, cliWarn } from '../utils/output' -import { EnvService, ProjectService, EnvironmentService } from '../services' +import { CliError } from '../services/api-error' +import { EnvService, ProjectService, EnvironmentService, SyncService } from '../services' export default defineCommand({ meta: { @@ -29,9 +31,20 @@ export default defineCommand({ confirmChanges, autoCreateProject, defaultEnv, + autoUppercase, + sync, } = await loadShelveConfig(true) const skipConfirm = args.yes || shouldSkipConfirm() + const env = args.env || defaultEnv + if (!env) { + throw new CliError( + 'Environment name is required.', + 'MISSING_ENV', + undefined, + 'Pass --env or set defaultEnv in shelve.json / SHELVE_DEFAULT_ENV.', + ) + } if (isAgentShell() && !skipConfirm) { cliError({ @@ -53,19 +66,30 @@ export default defineCommand({ cliIntro(`Pulling variable from ${project} project`) const projectData = await ProjectService.getProjectByName(project, slug, autoCreateProject) - - const env = args.env || defaultEnv - const environment = await EnvironmentService.getEnvironment(slug, env) + const policy = getResolvedSyncPolicy(environment.name, sync, projectData.syncPolicy) + assertPullAllowed(policy, environment.name) - const variables = await EnvService.getEnvVariables({ project: projectData, environmentId: environment.id, slug }) + const syncContext = await SyncService.loadSyncContext({ + project: projectData, + environmentId: environment.id, + environmentName: environment.name, + slug, + autoUppercase, + }) - const effectiveConfirmChanges = skipConfirm ? false : confirmChanges + const variables = SyncService.mergeForPull(syncContext, autoUppercase) + const effectiveConfirmChanges = skipConfirm ? false : (confirmChanges || policy.requireConfirmation) if (variables.length === 0) { cliWarn('No variables found in the specified environment') } else { - await EnvService.createEnvFile({ envFileName, variables, confirmChanges: effectiveConfirmChanges }) + await EnvService.createEnvFile({ + envFileName, + variables, + confirmChanges: effectiveConfirmChanges, + pullMode: policy.pullMode, + }) } cliSuccess( @@ -74,6 +98,8 @@ export default defineCommand({ variableCount: variables.length, file: envFileName, keys: variables.map(v => v.key), + pullMode: policy.pullMode, + preservedLocalKeys: policy.pullMode === 'merge' ? syncContext.diff.onlyLocal : [], }, `Successfully pulled variable from ${environment.name} environment`, 'pull', diff --git a/packages/cli/src/commands/push.ts b/packages/cli/src/commands/push.ts index 02b4875c..a2c0ec03 100644 --- a/packages/cli/src/commands/push.ts +++ b/packages/cli/src/commands/push.ts @@ -1,9 +1,9 @@ import { defineCommand } from 'citty' -import { loadShelveConfig } from '../utils' -import { isNonInteractive } from '../utils/cli-context' -import { cliIntro, cliSuccess } from '../utils/output' +import { loadShelveConfig, assertSyncConfirmationAllowed } from '../utils' +import { assertPushAllowed, getResolvedSyncPolicy } from '../utils/sync-policy' +import { cliIntro, cliSuccess, cliWarn } from '../utils/output' import { CliError } from '../services/api-error' -import { EnvService, ProjectService, EnvironmentService } from '../services' +import { EnvService, ProjectService, EnvironmentService, SyncService } from '../services' export default defineCommand({ meta: { @@ -30,47 +30,87 @@ export default defineCommand({ autoUppercase, autoCreateProject, defaultEnv, + sync, } = await loadShelveConfig(true) const confirmed = Boolean(args.yes) - if (confirmChanges && !confirmed && isNonInteractive()) { + const env = args.env || defaultEnv + if (!env) { throw new CliError( - 'Push confirmation is required.', - 'CONFIRMATION_REQUIRED', + 'Environment name is required.', + 'MISSING_ENV', undefined, - 'Pass --yes to confirm pushing variables in non-interactive mode.', + 'Pass --env or set defaultEnv in shelve.json / SHELVE_DEFAULT_ENV.', ) } - const effectiveConfirmChanges = confirmed ? false : confirmChanges cliIntro(`Pushing variable to ${project} project`) const projectData = await ProjectService.getProjectByName(project, slug, autoCreateProject) + const environment = await EnvironmentService.getEnvironment(slug, env) + const policy = getResolvedSyncPolicy(environment.name, sync, projectData.syncPolicy) + assertPushAllowed(policy, environment.name) - const env = args.env || defaultEnv + assertSyncConfirmationAllowed( + confirmChanges, + policy.requireConfirmation, + confirmed, + 'Push confirmation is required.', + ) + const effectiveConfirmChanges = confirmed ? false : (confirmChanges || policy.requireConfirmation) - const environment = await EnvironmentService.getEnvironment(slug, env) - const variables = await EnvService.getEnvFile() + const syncContext = await SyncService.loadSyncContext({ + project: projectData, + environmentId: environment.id, + environmentName: environment.name, + slug, + autoUppercase, + }) + + const { variables, skippedKeys, conflictKeys } = await SyncService.preparePushVariables( + syncContext, + autoUppercase, + confirmed, + ) - const pushed = await EnvService.pushEnvFile({ + const pushResult = await EnvService.pushEnvFile({ variables, project: projectData, environment, confirmChanges: effectiveConfirmChanges, autoUppercase, slug, + syncPolicy: policy, }) - if (pushed) { + const result = { ...pushResult, skippedKeys, conflictKeys } + + if (skippedKeys.length > 0) { + cliWarn(`Skipped ${skippedKeys.length} key(s): ${skippedKeys.join(', ')}`) + } + + if (result.pushed) { cliSuccess( - { env: environment.name, variableCount: variables.length, pushed: true }, + { + env: environment.name, + variableCount: result.variableCount, + pushed: true, + skippedKeys, + conflictKeys, + }, `Successfully pushed variable to ${environment.name} environment`, 'push', ) } else { cliSuccess( - { env: environment.name, variableCount: variables.length, pushed: false }, - 'Variable push was cancelled', + { + env: environment.name, + variableCount: 0, + pushed: false, + skippedKeys, + conflictKeys, + }, + 'Nothing to push', 'push', ) } diff --git a/packages/cli/src/commands/sync.ts b/packages/cli/src/commands/sync.ts new file mode 100644 index 00000000..6271026b --- /dev/null +++ b/packages/cli/src/commands/sync.ts @@ -0,0 +1,147 @@ +import { defineCommand } from 'citty' +import { assertSyncConfirmationAllowed, loadShelveConfig } from '../utils' +import { assertPullAllowed, assertPushAllowed, getResolvedSyncPolicy } from '../utils/sync-policy' +import { cliIntro, cliSuccess, cliWarn } from '../utils/output' +import { CliError } from '../services/api-error' +import { EnvService, EnvironmentService, ProjectService, SyncService } from '../services' + +export default defineCommand({ + meta: { + name: 'sync', + description: 'Apply sync policy (push or pull based on sourceOfTruth)', + }, + args: { + env: { + type: 'string', + description: 'Environment to sync', + required: false, + }, + 'dry-run': { + type: 'boolean', + description: 'Show what would happen without writing', + required: false, + }, + yes: { + type: 'boolean', + description: 'Skip confirmation prompts', + required: false, + }, + }, + async run({ args }) { + const config = await loadShelveConfig(true) + const { + project, + slug, + confirmChanges, + autoUppercase, + autoCreateProject, + defaultEnv, + sync, + envFileName, + } = config + + const env = args.env || defaultEnv + if (!env) { + throw new CliError( + 'Environment name is required.', + 'MISSING_ENV', + undefined, + 'Pass --env or set defaultEnv in shelve.json / SHELVE_DEFAULT_ENV.', + ) + } + + const projectData = await ProjectService.getProjectByName(project, slug, autoCreateProject) + const environment = await EnvironmentService.getEnvironment(slug, env) + const policy = getResolvedSyncPolicy(environment.name, sync, projectData.syncPolicy) + + const syncContext = await SyncService.loadSyncContext({ + project: projectData, + environmentId: environment.id, + environmentName: environment.name, + slug, + autoUppercase, + }) + + const action = policy.sourceOfTruth === 'local' ? 'push' : 'pull' + const dryRun = Boolean(args['dry-run']) + const skipConfirm = Boolean(args.yes) + + cliIntro(dryRun ? `Sync dry-run (${action}) for ${environment.name}` : `Syncing (${action}) ${environment.name}`) + + if (dryRun) { + cliSuccess( + { + env: environment.name, + action, + policy, + diff: syncContext.diff, + dryRun: true, + }, + `Would ${action} per sourceOfTruth: ${policy.sourceOfTruth}`, + 'sync', + ) + return + } + + if (action === 'push') { + assertPushAllowed(policy, environment.name) + assertSyncConfirmationAllowed( + confirmChanges, + policy.requireConfirmation, + skipConfirm, + 'Sync push confirmation is required.', + ) + + const { variables, skippedKeys, conflictKeys } = await SyncService.preparePushVariables( + syncContext, + autoUppercase, + skipConfirm, + ) + + const pushResult = await EnvService.pushEnvFile({ + variables, + project: projectData, + environment, + confirmChanges: skipConfirm ? false : (confirmChanges || policy.requireConfirmation), + autoUppercase, + slug, + syncPolicy: policy, + }) + + cliSuccess( + { env: environment.name, action: 'push', ...pushResult, skippedKeys, conflictKeys }, + pushResult.pushed ? 'Sync push complete' : 'Nothing to push', + 'sync', + ) + return + } + + assertPullAllowed(policy, environment.name) + const variables = SyncService.mergeForPull(syncContext, autoUppercase) + if (variables.length === 0) { + cliWarn('No variables to pull') + cliSuccess({ env: environment.name, action: 'pull', variableCount: 0 }, 'Nothing to pull', 'sync') + return + } + + await EnvService.createEnvFile({ + envFileName, + variables, + confirmChanges: skipConfirm ? false : (confirmChanges || policy.requireConfirmation), + pullMode: policy.pullMode, + }) + + cliSuccess( + { + env: environment.name, + action: 'pull', + variableCount: variables.length, + file: envFileName, + pullMode: policy.pullMode, + keys: variables.map(v => v.key), + }, + `Sync pull complete for ${environment.name}`, + 'sync', + ) + }, +}) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index f164300f..dfa37c43 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -10,6 +10,8 @@ import { CliError } from './services/api-error' import { cliError } from './utils/output' import push from './commands/push' import pull from './commands/pull' +import diff from './commands/diff' +import sync from './commands/sync' import config from './commands/config' import generate from './commands/generate' import create from './commands/create' @@ -58,6 +60,8 @@ ${formatErrorCodesHelp()}`, run, push, pull, + diff, + sync, login, logout, me, diff --git a/packages/cli/src/services/env.ts b/packages/cli/src/services/env.ts index 0ab321bc..a9fb037e 100644 --- a/packages/cli/src/services/env.ts +++ b/packages/cli/src/services/env.ts @@ -4,6 +4,7 @@ import type { EnvVarExport, CreateEnvFileInput, PushEnvFileInput, + PushEnvFileResult, CreateVariablesInput, GetEnvVariables } from '@types' @@ -69,7 +70,7 @@ export class EnvService extends BaseService { } static async createEnvFile(input: CreateEnvFileInput): Promise { - const { envFileName, variables, confirmChanges } = input + const { envFileName, variables, confirmChanges, pullMode = 'replace' } = input if (confirmChanges) await askBoolean(`Are you sure you want to update ${envFileName} file?`) @@ -77,7 +78,7 @@ export class EnvService extends BaseService { await this.withLoading(`Creating ${envFileName} and ${envFileName}.example files`, async () => { const content = this.formatEnvContent(variables) - if (FileService.exists(envFileName)) FileService.delete(envFileName) + if (pullMode === 'replace' && FileService.exists(envFileName)) FileService.delete(envFileName) FileService.write(envFileName, content) const exampleVars: EnvVarExport[] = variables.map((v) => ({ @@ -100,12 +101,12 @@ export class EnvService extends BaseService { return this.withLoading('Fetch variables', () => this.request(endpoint)) } - static async pushEnvFile(input: PushEnvFileInput): Promise { + static async pushEnvFile(input: PushEnvFileInput): Promise { const { variables, project, slug, environment, confirmChanges, autoUppercase } = input if (variables.length === 0) { - cliWarn('No variables found in the .env file') - return false + cliWarn('No variables to push') + return { pushed: false, variableCount: 0, skippedKeys: [], conflictKeys: [] } } if (confirmChanges) @@ -127,7 +128,7 @@ export class EnvService extends BaseService { }) }) - return true + return { pushed: true, variableCount: variables.length, skippedKeys: [], conflictKeys: [] } } static async generateEnvExampleFile(): Promise { diff --git a/packages/cli/src/services/index.ts b/packages/cli/src/services/index.ts index 8b531355..c3117d19 100644 --- a/packages/cli/src/services/index.ts +++ b/packages/cli/src/services/index.ts @@ -5,3 +5,4 @@ export { EnvironmentService } from './environment' export { PkgService } from './pkg' export { CredentialsService } from './credentials' export { CacheService, cacheFilePath, type CacheKeyInput } from './cache' +export { SyncService } from './sync' diff --git a/packages/cli/src/services/sync.ts b/packages/cli/src/services/sync.ts new file mode 100644 index 00000000..94f75ef4 --- /dev/null +++ b/packages/cli/src/services/sync.ts @@ -0,0 +1,148 @@ +import { + diffEnvVars, + excludeVarsByKeys, + mergeEnvVarsForPull, + type EnvDiffResult, + type EnvVar, + type EnvVarExport, + type Project, + type ResolvedSyncPolicy, + type ShelveSyncConfig, +} from '@types' +import { multiselect, isCancel } from '@clack/prompts' +import { getResolvedSyncPolicy } from '../utils/sync-policy' +import { loadShelveConfig } from '../utils/config' +import { askBoolean, isNonInteractive, shouldSkipConfirm } from '../utils' +import { cliCancel } from '../utils/output' +import { EnvService } from './env' +import { CliError } from './api-error' + +export type SyncContext = { + policy: ResolvedSyncPolicy + diff: EnvDiffResult + local: EnvVar[] + remote: EnvVarExport[] +} + +export type LoadSyncContextInput = { + project: Project + environmentId: number + environmentName: string + slug: string + autoUppercase: boolean +} + +export class SyncService { + + static resolvePolicy( + environmentName: string, + project?: Project, + fileSync?: ShelveSyncConfig, + ): ResolvedSyncPolicy { + const configSync = fileSync ?? undefined + return getResolvedSyncPolicy(environmentName, configSync, project?.syncPolicy) + } + + static async loadSyncContext(input: LoadSyncContextInput): Promise { + const config = await loadShelveConfig() + const policy = this.resolvePolicy(input.environmentName, input.project, config.sync) + const local = await EnvService.getEnvFile() + const remote = await EnvService.getEnvVariables({ + project: input.project, + environmentId: input.environmentId, + slug: input.slug, + quiet: true, + }) + const diff = diffEnvVars(local, remote, input.autoUppercase) + return { policy, diff, local, remote } + } + + static async preparePushVariables( + context: SyncContext, + autoUppercase: boolean, + skipConfirm: boolean, + ): Promise<{ variables: EnvVar[], skippedKeys: string[], conflictKeys: string[] }> { + const { policy, diff, local } = context + const conflictKeys = [...diff.changed] + let variables = [...local] + const skippedKeys: string[] = [] + + if (conflictKeys.length === 0) { + return { variables, skippedKeys, conflictKeys: [] } + } + + if (policy.onPushConflict === 'overwrite') { + return { variables, skippedKeys, conflictKeys } + } + + const conflictSet = new Set(conflictKeys) + + if (policy.onPushConflict === 'fail') { + throw new CliError( + `Push blocked: ${conflictKeys.length} variable(s) differ on Shelve (${conflictKeys.join(', ')}).`, + 'SYNC_CONFLICT', + undefined, + 'Run `shelve diff`, align values, or set sync.onPushConflict to overwrite/skip/prompt.', + ) + } + + if (policy.onPushConflict === 'skip') { + variables = excludeVarsByKeys(variables, conflictSet, autoUppercase) + skippedKeys.push(...conflictKeys) + return { variables, skippedKeys, conflictKeys } + } + + if (skipConfirm || shouldSkipConfirm()) { + variables = excludeVarsByKeys(variables, conflictSet, autoUppercase) + skippedKeys.push(...conflictKeys) + return { variables, skippedKeys, conflictKeys } + } + + if (isNonInteractive()) { + throw new CliError( + `Push has ${conflictKeys.length} conflicting key(s) and onPushConflict is "prompt".`, + 'SYNC_CONFLICT', + undefined, + 'Pass --yes to skip conflicting keys, or set onPushConflict to overwrite/fail/skip.', + ) + } + + const selected = await multiselect({ + message: 'Select conflicting keys to push (unselected keys are skipped):', + options: conflictKeys.map(key => ({ value: key, label: key })), + required: false, + }) + if (isCancel(selected)) cliCancel('Push cancelled.') + + const pushSet = new Set(selected as string[]) + const skipFromPrompt = conflictKeys.filter(k => !pushSet.has(k)) + variables = excludeVarsByKeys(variables, new Set(skipFromPrompt), autoUppercase) + skippedKeys.push(...skipFromPrompt) + + return { variables, skippedKeys, conflictKeys } + } + + static mergeForPull( + context: SyncContext, + autoUppercase: boolean, + ): EnvVarExport[] { + if (context.policy.pullMode === 'replace') { + return context.remote + } + return mergeEnvVarsForPull(context.local, context.remote, autoUppercase) + } + + static async confirmIfRequired( + policy: ResolvedSyncPolicy, + confirmChanges: boolean, + skipConfirm: boolean, + message: string, + ): Promise { + const needsConfirm = (confirmChanges || policy.requireConfirmation) && !skipConfirm + if (!needsConfirm) return + + const response = await askBoolean(message) + if (isCancel(response) || response === false) cliCancel('Operation cancelled.') + } + +} diff --git a/packages/cli/src/utils/confirmation.ts b/packages/cli/src/utils/confirmation.ts new file mode 100644 index 00000000..419700bc --- /dev/null +++ b/packages/cli/src/utils/confirmation.ts @@ -0,0 +1,18 @@ +import { CliError } from '../services/api-error' +import { isNonInteractive } from './cli-context' + +export function assertSyncConfirmationAllowed( + confirmChanges: boolean, + requireConfirmation: boolean, + skipConfirm: boolean, + message = 'Sync confirmation is required.', +): void { + if ((confirmChanges || requireConfirmation) && !skipConfirm && isNonInteractive()) { + throw new CliError( + message, + 'CONFIRMATION_REQUIRED', + undefined, + 'Pass --yes in non-interactive mode.', + ) + } +} diff --git a/packages/cli/src/utils/error-codes.ts b/packages/cli/src/utils/error-codes.ts index 1810cf1f..2b8a220a 100644 --- a/packages/cli/src/utils/error-codes.ts +++ b/packages/cli/src/utils/error-codes.ts @@ -60,6 +60,22 @@ export const CLI_ERROR_CODES: Record USER_CANCELLED: { meaning: 'User aborted an interactive prompt', }, + PUSH_BLOCKED: { + meaning: 'Push is disabled for this environment by sync policy', + hint: 'Check sync.protectedEnvironments or sync.environments..allowPush.', + }, + PULL_BLOCKED: { + meaning: 'Pull is disabled for this environment by sync policy', + hint: 'Set sync.environments..allowPull to true.', + }, + SYNC_CONFLICT: { + meaning: 'Local and remote values differ and onPushConflict prevented the push', + hint: 'Run `shelve diff` or use onPushConflict overwrite/skip/prompt with --yes.', + }, + ENV_PROTECTED: { + meaning: 'Server rejected a write to a protected environment', + hint: 'Update project sync policy in Shelve settings or use a different environment.', + }, } export function formatErrorCodesHelp(): string { diff --git a/packages/cli/src/utils/index.ts b/packages/cli/src/utils/index.ts index 7d2072c1..0aad0b74 100644 --- a/packages/cli/src/utils/index.ts +++ b/packages/cli/src/utils/index.ts @@ -15,6 +15,8 @@ export * from './ignore-files' export * from './cli-context' export * from './output' export * from './error-codes' +export * from './sync-policy' +export * from './confirmation' const s = spinner() diff --git a/packages/cli/src/utils/prompt.ts b/packages/cli/src/utils/prompt.ts index b7b6098a..ea339652 100644 --- a/packages/cli/src/utils/prompt.ts +++ b/packages/cli/src/utils/prompt.ts @@ -16,8 +16,8 @@ export async function askBoolean(message: string): Promise { } const response = await confirm({ message }) - if (!response) return cliCancel('Operation cancelled.') - return response + if (isCancel(response) || response === false) cliCancel('Operation cancelled.') + return true } export async function askSelect( diff --git a/packages/cli/src/utils/sync-policy.ts b/packages/cli/src/utils/sync-policy.ts new file mode 100644 index 00000000..a1369840 --- /dev/null +++ b/packages/cli/src/utils/sync-policy.ts @@ -0,0 +1,55 @@ +import { + mergeSyncPolicies, + resolveSyncPolicy, + type ResolvedSyncPolicy, + type ShelveSyncConfig, +} from '@types' +import { CliError } from '../services/api-error' + +export function getEffectiveSyncConfig( + fileSync: ShelveSyncConfig | undefined, + serverSync: ShelveSyncConfig | null | undefined, +): ShelveSyncConfig | undefined { + return mergeSyncPolicies(fileSync, serverSync ?? undefined) +} + +export function getResolvedSyncPolicy( + environmentName: string, + fileSync?: ShelveSyncConfig | undefined, + serverSync?: ShelveSyncConfig | null | undefined, +): ResolvedSyncPolicy { + const merged = getEffectiveSyncConfig(fileSync, serverSync) + const envOverrides: { allowPush?: boolean } = {} + if (process.env.SHELVE_SYNC_ALLOW_PUSH === '0' || process.env.SHELVE_SYNC_ALLOW_PUSH === 'false') { + envOverrides.allowPush = false + } + if (process.env.SHELVE_SYNC_ALLOW_PULL === '0' || process.env.SHELVE_SYNC_ALLOW_PULL === 'false') { + return { + ...resolveSyncPolicy(environmentName, merged, envOverrides), + allowPull: false, + } + } + return resolveSyncPolicy(environmentName, merged, envOverrides) +} + +export function assertPushAllowed(policy: ResolvedSyncPolicy, environmentName: string): void { + if (!policy.allowPush) { + throw new CliError( + `Push to "${environmentName}" is blocked by sync policy.`, + 'PUSH_BLOCKED', + undefined, + 'Remove the environment from sync.protectedEnvironments or set allowPush: true.', + ) + } +} + +export function assertPullAllowed(policy: ResolvedSyncPolicy, environmentName: string): void { + if (!policy.allowPull) { + throw new CliError( + `Pull from "${environmentName}" is blocked by sync policy.`, + 'PULL_BLOCKED', + undefined, + 'Set sync.environments..allowPull to true.', + ) + } +} diff --git a/packages/cli/test/sync-policy.test.ts b/packages/cli/test/sync-policy.test.ts new file mode 100644 index 00000000..90b1b687 --- /dev/null +++ b/packages/cli/test/sync-policy.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from 'vitest' +import { + diffEnvVars, + mergeEnvVarsForPull, + mergeSyncPolicies, + resolveSyncPolicy, +} from '@types' +import { getResolvedSyncPolicy } from '../src/utils/sync-policy' + +describe('resolveSyncPolicy', () => { + it('uses defaults when sync is undefined', () => { + const policy = resolveSyncPolicy('development') + expect(policy.allowPush).toBe(true) + expect(policy.pullMode).toBe('replace') + expect(policy.onPushConflict).toBe('overwrite') + }) + + it('blocks push for protected environments', () => { + const policy = resolveSyncPolicy('production', { + protectedEnvironments: ['production'], + }) + expect(policy.allowPush).toBe(false) + }) + + it('respects allowPull false', () => { + const policy = resolveSyncPolicy('staging', { + environments: { staging: { allowPull: false } }, + }) + expect(policy.allowPull).toBe(false) + }) + + it('uses pullMode merge from config', () => { + const policy = resolveSyncPolicy('development', { + default: { pullMode: 'merge' }, + }) + expect(policy.pullMode).toBe('merge') + }) + + it('merges per-environment overrides', () => { + const policy = resolveSyncPolicy('staging', { + default: { onPushConflict: 'skip' }, + environments: { staging: { onPushConflict: 'fail' } }, + }) + expect(policy.onPushConflict).toBe('fail') + }) +}) + +describe('mergeSyncPolicies', () => { + it('server allowPush false wins over file true', () => { + const merged = mergeSyncPolicies( + { environments: { production: { allowPush: true } } }, + { environments: { production: { allowPush: false } } }, + ) + expect(resolveSyncPolicy('production', merged).allowPush).toBe(false) + }) +}) + +describe('getResolvedSyncPolicy env override', () => { + it('disables push when SHELVE_SYNC_ALLOW_PUSH=0', () => { + const prev = process.env.SHELVE_SYNC_ALLOW_PUSH + process.env.SHELVE_SYNC_ALLOW_PUSH = '0' + try { + expect(getResolvedSyncPolicy('development').allowPush).toBe(false) + } finally { + if (prev === undefined) delete process.env.SHELVE_SYNC_ALLOW_PUSH + else process.env.SHELVE_SYNC_ALLOW_PUSH = prev + } + }) +}) + +describe('diffEnvVars', () => { + it('classifies onlyLocal, onlyRemote, changed, unchanged', () => { + const diff = diffEnvVars( + [ + { key: 'A', value: '1' }, + { key: 'B', value: 'local' }, + { key: 'C', value: 'same' }, + ], + [ + { key: 'B', value: 'remote' }, + { key: 'C', value: 'same' }, + { key: 'D', value: '2' }, + ], + ) + expect(diff.onlyLocal).toEqual(['A']) + expect(diff.onlyRemote).toEqual(['D']) + expect(diff.changed).toEqual(['B']) + expect(diff.unchanged).toEqual(['C']) + }) +}) + +describe('mergeEnvVarsForPull', () => { + it('keeps local-only keys when merging', () => { + const merged = mergeEnvVarsForPull( + [{ key: 'LOCAL_ONLY', value: 'x' }], + [{ key: 'REMOTE', value: 'y', description: undefined }], + ) + expect(merged.map(v => v.key).sort()).toEqual(['LOCAL_ONLY', 'REMOTE']) + expect(merged.find(v => v.key === 'REMOTE')?.value).toBe('y') + }) +}) diff --git a/packages/types/index.ts b/packages/types/index.ts index fcd818cc..cd17754a 100644 --- a/packages/types/index.ts +++ b/packages/types/index.ts @@ -5,6 +5,7 @@ export * from './src/Team' export * from './src/Token' export * from './src/Audit' export * from './src/Cli' +export * from './src/Sync' export * from './src/Vault' export * from './src/Environment' export * from './src/Stats' diff --git a/packages/types/schema.json b/packages/types/schema.json index 646f812f..6a8813d3 100644 --- a/packages/types/schema.json +++ b/packages/types/schema.json @@ -39,6 +39,37 @@ "type": "boolean", "description": "Whether to automatically create a project if it doesn't exist", "default": true + }, + "sync": { + "type": "object", + "description": "Sync policies for push, pull, and conflict resolution", + "properties": { + "default": { "$ref": "#/definitions/syncPolicy" }, + "environments": { + "type": "object", + "additionalProperties": { "$ref": "#/definitions/syncPolicy" } + }, + "protectedEnvironments": { + "type": "array", + "items": { "type": "string" }, + "description": "Environment names where push is blocked" + } + }, + "additionalProperties": false + } + }, + "definitions": { + "syncPolicy": { + "type": "object", + "properties": { + "sourceOfTruth": { "type": "string", "enum": ["remote", "local"] }, + "onPushConflict": { "type": "string", "enum": ["overwrite", "skip", "fail", "prompt"] }, + "pullMode": { "type": "string", "enum": ["replace", "merge"] }, + "allowPush": { "type": "boolean" }, + "allowPull": { "type": "boolean" }, + "requireConfirmation": { "type": "boolean" } + }, + "additionalProperties": false } } } diff --git a/packages/types/src/Cli.ts b/packages/types/src/Cli.ts index eb10cf61..9d60c640 100644 --- a/packages/types/src/Cli.ts +++ b/packages/types/src/Cli.ts @@ -1,6 +1,7 @@ import type { EnvVar, EnvVarExport } from './Variables' import type { Environment } from './Environment' import type { Project } from './Project' +import type { ResolvedSyncPolicy, ShelveSyncConfig } from './Sync' export const SHELVE_JSON_SCHEMA = 'https://raw.githubusercontent.com/HugoRCD/shelve/main/packages/types/schema.json' export const DEFAULT_URL = 'https://app.shelve.cloud' @@ -77,6 +78,10 @@ export type ShelveConfig = { * Whether the project is at the root level * */ isRoot: boolean + /** + * Sync policies for push, pull, and conflict resolution (CLI; merged with server policy when applicable). + */ + sync?: ShelveSyncConfig } export type CreateEnvFileInput = { @@ -96,6 +101,11 @@ export type CreateEnvFileInput = { * @default false */ confirmChanges: boolean + /** + * How to merge remote variables into the local env file. + * @default 'replace' + */ + pullMode?: 'replace' | 'merge' } export type PushEnvFileInput = { @@ -127,6 +137,14 @@ export type PushEnvFileInput = { * @default true * */ autoUppercase: boolean + syncPolicy: ResolvedSyncPolicy +} + +export type PushEnvFileResult = { + pushed: boolean + variableCount: number + skippedKeys: string[] + conflictKeys: string[] } export type GetEnvVariables = { diff --git a/packages/types/src/Project.ts b/packages/types/src/Project.ts index 01fe3a13..475830f6 100644 --- a/packages/types/src/Project.ts +++ b/packages/types/src/Project.ts @@ -1,3 +1,5 @@ +import type { ShelveSyncConfig } from './Sync' + export type Project = { id: number; name: string; @@ -8,6 +10,7 @@ export type Project = { variablePrefix: string; logo: string; teamId: number; + syncPolicy?: ShelveSyncConfig | null; createdAt: Date; updatedAt: Date; }; @@ -33,5 +36,6 @@ export type ProjectUpdateInput = { projectManager?: string; variablePrefix?: string; teamId: number; + syncPolicy?: ShelveSyncConfig | null; }; diff --git a/packages/types/src/Sync.ts b/packages/types/src/Sync.ts new file mode 100644 index 00000000..cda0200f --- /dev/null +++ b/packages/types/src/Sync.ts @@ -0,0 +1,199 @@ +import type { EnvVar, EnvVarExport } from './Variables' + +export type SourceOfTruth = 'remote' | 'local' + +export type OnPushConflict = 'overwrite' | 'skip' | 'fail' | 'prompt' + +export type PullMode = 'replace' | 'merge' + +export type SyncPolicy = { + sourceOfTruth?: SourceOfTruth + onPushConflict?: OnPushConflict + pullMode?: PullMode + allowPush?: boolean + allowPull?: boolean + requireConfirmation?: boolean +} + +export type ShelveSyncConfig = { + default?: SyncPolicy + environments?: Record + protectedEnvironments?: string[] +} + +export const DEFAULT_SYNC_POLICY: Required> = { + sourceOfTruth: 'remote', + onPushConflict: 'overwrite', + pullMode: 'replace', + allowPush: true, + allowPull: true, +} + +export type ResolvedSyncPolicy = SyncPolicy & typeof DEFAULT_SYNC_POLICY & { + requireConfirmation: boolean +} + +export type EnvDiffResult = { + onlyLocal: string[] + onlyRemote: string[] + changed: string[] + unchanged: string[] +} + +type KeyedVar = { key: string, value: string } + +function normalizeKey(key: string, autoUppercase?: boolean): string { + return autoUppercase ? key.toUpperCase() : key +} + +function toKeyedMap(vars: KeyedVar[], autoUppercase?: boolean): Map { + const map = new Map() + for (const v of vars) { + map.set(normalizeKey(v.key, autoUppercase), v.value) + } + return map +} + +export function resolveSyncPolicy( + environmentName: string, + sync?: ShelveSyncConfig | null, + envOverrides?: { allowPush?: boolean }, +): ResolvedSyncPolicy { + const envPolicy = sync?.environments?.[environmentName] + const merged: SyncPolicy = { + ...DEFAULT_SYNC_POLICY, + ...sync?.default, + ...envPolicy, + } + + const protectedEnvs = sync?.protectedEnvironments ?? [] + if (protectedEnvs.includes(environmentName)) { + merged.allowPush = false + } + + if (envOverrides?.allowPush === false) { + merged.allowPush = false + } + + return { + sourceOfTruth: merged.sourceOfTruth ?? DEFAULT_SYNC_POLICY.sourceOfTruth, + onPushConflict: merged.onPushConflict ?? DEFAULT_SYNC_POLICY.onPushConflict, + pullMode: merged.pullMode ?? DEFAULT_SYNC_POLICY.pullMode, + allowPush: merged.allowPush ?? DEFAULT_SYNC_POLICY.allowPush, + allowPull: merged.allowPull ?? DEFAULT_SYNC_POLICY.allowPull, + requireConfirmation: merged.requireConfirmation ?? false, + } +} + +/** Server guardrails win when they are stricter (allow* false). */ +export function mergeSyncPolicies( + fileSync?: ShelveSyncConfig | null, + serverSync?: ShelveSyncConfig | null, +): ShelveSyncConfig | undefined { + if (!fileSync && !serverSync) return undefined + if (!serverSync) return fileSync ?? undefined + if (!fileSync) return serverSync + + const envNames = new Set([ + ...Object.keys(fileSync.environments ?? {}), + ...Object.keys(serverSync.environments ?? {}), + ]) + + const environments: Record = {} + for (const name of envNames) { + const fileEnv = fileSync.environments?.[name] + const serverEnv = serverSync.environments?.[name] + const merged = mergeSyncPolicyPair(fileEnv, serverEnv) + if (merged) environments[name] = merged + } + + return { + default: mergeSyncPolicyPair(fileSync.default, serverSync.default), + environments: Object.keys(environments).length ? environments : undefined, + protectedEnvironments: [ + ...new Set([ + ...(fileSync.protectedEnvironments ?? []), + ...(serverSync.protectedEnvironments ?? []), + ]), + ], + } +} + +function mergeSyncPolicyPair(file?: SyncPolicy, server?: SyncPolicy): SyncPolicy | undefined { + if (!file && !server) return undefined + return { + ...file, + ...server, + allowPush: server?.allowPush === false ? false : file?.allowPush, + allowPull: server?.allowPull === false ? false : file?.allowPull, + } +} + +export function diffEnvVars( + local: KeyedVar[], + remote: KeyedVar[], + autoUppercase = true, +): EnvDiffResult { + const localMap = toKeyedMap(local, autoUppercase) + const remoteMap = toKeyedMap(remote, autoUppercase) + + const onlyLocal: string[] = [] + const onlyRemote: string[] = [] + const changed: string[] = [] + const unchanged: string[] = [] + + for (const [key, localValue] of localMap) { + if (!remoteMap.has(key)) { + onlyLocal.push(key) + continue + } + const remoteValue = remoteMap.get(key)! + if (localValue !== remoteValue) changed.push(key) + else unchanged.push(key) + } + + for (const key of remoteMap.keys()) { + if (!localMap.has(key)) onlyRemote.push(key) + } + + onlyLocal.sort() + onlyRemote.sort() + changed.sort() + unchanged.sort() + + return { onlyLocal, onlyRemote, changed, unchanged } +} + +export function filterVarsByKeys( + variables: T[], + keys: Set, + autoUppercase = true, +): T[] { + return variables.filter(v => keys.has(normalizeKey(v.key, autoUppercase))) +} + +export function excludeVarsByKeys( + variables: T[], + keys: Set, + autoUppercase = true, +): T[] { + return variables.filter(v => !keys.has(normalizeKey(v.key, autoUppercase))) +} + +export function mergeEnvVarsForPull( + local: EnvVar[], + remote: EnvVarExport[], + autoUppercase = true, +): EnvVarExport[] { + const diff = diffEnvVars(local, remote, autoUppercase) + const onlyLocalSet = new Set(diff.onlyLocal) + const localOnly = filterVarsByKeys(local, onlyLocalSet, autoUppercase).map(v => ({ + key: normalizeKey(v.key, autoUppercase), + value: v.value, + })) + const remoteNormalized = remote.map(v => ({ + ...v, + key: normalizeKey(v.key, autoUppercase), + })) + return [...remoteNormalized, ...localOnly] +}