Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ Good when you want to see drift across the whole branch, edit one variable witho
| [`capy run -- <cmd>`](https://docs.capy.sc/cli/run) | Run a command with decrypted secrets injected. |
| [`capy edit`](https://docs.capy.sc/cli/edit) | Inspect and edit secrets in an interactive TUI. |
| [`capy status`](https://docs.capy.sc/cli/status) | Show drift between local, pinned, and remote. |
| [`capy push`](https://docs.capy.sc/cli/push) | Push local changes without pulling. |
| [`capy push`](https://docs.capy.sc/cli/push) | Push local changes. Runs the same sync as `capy`, so any difference is settled first. |
| [`capy lock`](https://docs.capy.sc/cli/lock) | Lock the local key so the passphrase is required next time. |
| [`capy deploy`](https://docs.capy.sc/cli/deploy) | Generate a deploy token and walk through platform setup. |
| [`capy connect <provider>`](https://docs.capy.sc/cli/connect) | Pull a credential from a provider into `.env`. |
Expand Down Expand Up @@ -177,6 +177,21 @@ Good when you want to see drift across the whole branch, edit one variable witho

`capy` is a three-way diff between your local `.env`, the last pinned snapshot in `keep.lock`, and the latest on the service. Conflicts open an interactive resolver. See [docs.capy.sc/using/syncing-secrets](https://docs.capy.sc/using/syncing-secrets).

### Conflicts and agents

No command overwrites a value someone else changed since you last saw it. Every command that writes from a folder (`capy edit`, `add`, `remove`, `connect`, `rotate`, `push`, `deploy`, `org`, and `checkout` of an existing branch) runs a plain `capy` first. It repeats until your folder and Capy agree on every variable. If you cancel, nothing is written. Only then does the command apply its own change on top of Capy's latest. Every difference blocks the write, including ones unrelated to the variable you are changing. Exceptions: `capy checkout -b`, `capy decrypt` (and checkout while recovering from a seed phrase), new projects, and the org system store.

For each difference you choose one of four: `pinned` (the folder's `keep.lock`), `local` (your `.env`), `remote` (Capy's latest), or `delete`.

Off a terminal (`--non-tty`, `--json`, an agent, CI) nobody can be asked. The command writes nothing, exits **3**, and prints JSON on stdout with `"code": "SECRETS_CONFLICT"`, a `conflict_id`, and one row per difference (name, project, branch, and fingerprints of the pinned, local and remote values, never the values). Ask the person, then rerun the same command once with `--resolve NAME=pinned|local|remote|delete` for every row and `--conflict <conflict_id>`. A Dokploy platform value is answered as `NAME@TARGET=local|remote`. If anything moved in between, the command exits 3 again with `SECRETS_CONFLICT_STALE` and the fresh rows. There is no resolve-all, and an answer naming a source that has no value for that row is refused (only `delete` deletes).

```bash
capy push --non-tty # exit 3: SECRETS_CONFLICT, conflict_id c_...
capy push --non-tty --resolve API_KEY=local --conflict c_9f2e41a07b3d
```

The `capy secrets` edit screens and `capy secrets set` do the same check against the value you were shown. Uploads also carry the version they started from, and the server refuses a stale one. The CLI syncs again and retries once.

## Running your app

`capy run -- <cmd>` decrypts `.env` in memory and spawns your command with the values set as environment variables. Works in any runtime that reads env vars. See [docs.capy.sc/using/running-your-app](https://docs.capy.sc/using/running-your-app).
Expand All @@ -185,6 +200,8 @@ Good when you want to see drift across the whole branch, edit one variable witho

`capy deploy` walks through Vercel, Cloudflare, Docker, Fly, Railway, Render, Heroku, GitHub Actions, and AWS Lambda. See [docs.capy.sc/using/deploying](https://docs.capy.sc/using/deploying).

`capy deploy` runs a plain `capy` first, so it only ships values your folder and Capy agree on. For Dokploy, if a service's live value was changed outside Capy since the last delivery, you choose `local` (deliver Capy's value) or `remote` (keep the platform's value and skip it). Batch deploy from `capy secrets` delivers only the variables you picked.

## Team

`capy invite <email>` to add a teammate; `capy kick <email>` to remove one. Invite codes travel out-of-band; kicks are O(1) with no key rotation. See [docs.capy.sc/using/team/inviting](https://docs.capy.sc/using/team/inviting).
Expand Down Expand Up @@ -264,7 +281,7 @@ Not currently. The service component is closed. If self-hosting matters for your
<details>
<summary><strong>Does it work in CI?</strong></summary>

Yes. `capy deploy` generates `SECRETS_BLOB` and `PROJECT_KEY` to set as CI env vars; `capy run` in your build/test step does the rest. See the GitHub Actions guide at [docs.capy.sc/using/deploying/github-actions](https://docs.capy.sc/using/deploying/github-actions).
Yes. `capy deploy` generates `SECRETS_BLOB` and `PROJECT_KEY` to set as CI env vars; `capy run` in your build/test step does the rest. `capy deploy --yes` in CI exits 3 instead of proceeding if the folder and Capy differ on any variable; see [Conflicts and agents](#conflicts-and-agents). See the GitHub Actions guide at [docs.capy.sc/using/deploying/github-actions](https://docs.capy.sc/using/deploying/github-actions).
</details>

## Supply chain
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.9.11
0.9.12
59 changes: 50 additions & 9 deletions docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

# capy CLI reference

Version `0.9.11`. Generated from `capy help --json`.
Version `0.9.12`. Generated from `capy help --json`.

## Commands

Expand Down Expand Up @@ -48,6 +48,8 @@ capy edit [name] [options]
| `--pr` | create a PR with the keep.lock change (answers the prompt) | |
| `--no-pr` | do not create a PR with the keep.lock change | |
| `--pr-base <branch>` | base branch for the PR (answers the prompt) | |
| `--resolve <NAME=pinned|local|remote|delete>` | answer a difference from Capy: pinned, local, remote or delete; NAME@TARGET=local|remote for a platform value (repeatable, needs --conflict) | `[]` |
| `--conflict <conflict_id>` | the conflict_id a conflict returned: binds --resolve to what was seen | |

JSON support: yes (`--json`)

Expand Down Expand Up @@ -82,6 +84,9 @@ capy checkout <branch> [options]
| `--protected` | Mark as a protected branch (invite-only) | |
| `--no-protected` | Create it open to the project | |
| `--json` | emit machine-readable JSON instead of the human UI | |
| `--non-tty` | treat stdin as not a terminal; differences from Capy stop the checkout with the conflict_id (agents/CI) | |
| `--resolve <NAME=pinned|local|remote|delete>` | answer a difference from Capy: pinned, local, remote or delete; NAME@TARGET=local|remote for a platform value (repeatable, needs --conflict) | `[]` |
| `--conflict <conflict_id>` | the conflict_id a conflict returned: binds --resolve to what was seen | |

JSON support: yes (`--json`)

Expand All @@ -90,10 +95,14 @@ JSON support: yes (`--json`)
Push encrypted values to Keep

```
capy push
capy push [options]
```

_No options._
| Option | Description | Default |
|---|---|---|
| `--non-tty` | treat stdin as not a terminal; differences from Capy stop the push with the conflict_id (agents/CI) | |
| `--resolve <NAME=pinned|local|remote|delete>` | answer a difference from Capy: pinned, local, remote or delete; NAME@TARGET=local|remote for a platform value (repeatable, needs --conflict) | `[]` |
| `--conflict <conflict_id>` | the conflict_id a conflict returned: binds --resolve to what was seen | |

JSON support: no

Expand All @@ -108,7 +117,7 @@ capy deploy [target] [options]
| Option | Description | Default |
|---|---|---|
| `--target <id>` | adapter id; requires --yes (CI mode) | |
| `--yes` | skip all prompts (CI) | |
| `--yes` | skip all prompts (CI); any difference between the folder and Capy stops the deploy with exit 3 | |
| `--dry-run` | preflight + show plan, push nothing (target mode) | |
| `--force` | redeploy even when keep.lock is unchanged — bumps keep.lock to trigger CI | |
| `--edit` | re-enter the picker for an existing target | |
Expand All @@ -123,6 +132,8 @@ capy deploy [target] [options]
| `--plan <file>` | dokploy --discover: the plan file to check (see schemas.deploy_dokploy_plan in `capy help --json`) | |
| `--confirm <plan_id>` | dokploy --discover: write the plan that --dry-run printed (one PR per repo) | |
| `--base-url <url>` | dokploy --discover: the Dokploy dashboard URL (else the org system variable _CONNECTOR_DOKPLOY_BASE_URL) | |
| `--resolve <NAME=pinned|local|remote|delete>` | answer a difference from Capy: pinned, local, remote or delete; NAME@TARGET=local|remote for a platform value (repeatable, needs --conflict) | `[]` |
| `--conflict <conflict_id>` | the conflict_id a conflict returned: binds --resolve to what was seen | |

JSON support: yes (`--json`)

Expand All @@ -142,10 +153,14 @@ Find the Dokploy services that match Capy projects, check a plan for them, and w
Revoke a deploy token

```
capy deploy revoke <deployId>
capy deploy revoke <deployId> [options]
```

_No options._
| Option | Description | Default |
|---|---|---|
| `--non-tty` | treat stdin as not a terminal; differences from Capy stop the revoke with the conflict_id (agents/CI) | |
| `--resolve <NAME=pinned|local|remote|delete>` | answer a difference from Capy: pinned, local, remote or delete; NAME@TARGET=local|remote for a platform value (repeatable, needs --conflict) | `[]` |
| `--conflict <conflict_id>` | the conflict_id a conflict returned: binds --resolve to what was seen | |

JSON support: no

Expand Down Expand Up @@ -184,6 +199,9 @@ capy deploy targets-remove <name> [options]
| Option | Description | Default |
|---|---|---|
| `--no-deploy` | strip the config but skip the redeploy that would apply the revert | |
| `--non-tty` | treat stdin as not a terminal; differences from Capy stop the removal with the conflict_id (agents/CI) | |
| `--resolve <NAME=pinned|local|remote|delete>` | answer a difference from Capy: pinned, local, remote or delete; NAME@TARGET=local|remote for a platform value (repeatable, needs --conflict) | `[]` |
| `--conflict <conflict_id>` | the conflict_id a conflict returned: binds --resolve to what was seen | |

JSON support: no

Expand Down Expand Up @@ -452,10 +470,14 @@ JSON support: yes (`--json`)
Switch organization

```
capy org
capy org [options]
```

_No options._
| Option | Description | Default |
|---|---|---|
| `--non-tty` | treat stdin as not a terminal; differences from Capy stop the switch with the conflict_id (agents/CI) | |
| `--resolve <NAME=pinned|local|remote|delete>` | answer a difference from Capy: pinned, local, remote or delete; NAME@TARGET=local|remote for a platform value (repeatable, needs --conflict) | `[]` |
| `--conflict <conflict_id>` | the conflict_id a conflict returned: binds --resolve to what was seen | |

JSON support: no

Expand Down Expand Up @@ -551,6 +573,8 @@ capy secrets set <name> [options]
| `--no-pr-for <owner/name>` | do not open a PR in this repo (repeatable) | `[]` |
| `--no-pr` | do not open any PR | |
| `--confirm <plan_id>` | run the plan that --dry-run printed (required for a real run) | |
| `--resolve <NAME=pinned|local|remote|delete>` | answer a difference from Capy: pinned, local, remote or delete; NAME@TARGET=local|remote for a platform value (repeatable, needs --conflict) | `[]` |
| `--conflict <conflict_id>` | the conflict_id a conflict returned: binds --resolve to what was seen | |

JSON support: yes (`--json`)

Expand All @@ -571,6 +595,8 @@ capy secrets deploy <names...> [options]
| `--all-rows` | deploy every row of those names | |
| `--exclude <project:branch>` | leave this location out (repeatable) | `[]` |
| `--confirm <plan_id>` | run the plan that --dry-run printed (required for a real run) | |
| `--resolve <NAME=pinned|local|remote|delete>` | answer a difference from Capy: pinned, local, remote or delete; NAME@TARGET=local|remote for a platform value (repeatable, needs --conflict) | `[]` |
| `--conflict <conflict_id>` | the conflict_id a conflict returned: binds --resolve to what was seen | |

JSON support: yes (`--json`)

Expand Down Expand Up @@ -653,6 +679,8 @@ capy add <vars...> [options]
| `--pr` | create a PR with the keep.lock change (answers the prompt) | |
| `--no-pr` | do not create a PR with the keep.lock change | |
| `--pr-base <branch>` | base branch for the PR (answers the prompt) | |
| `--resolve <NAME=pinned|local|remote|delete>` | answer a difference from Capy: pinned, local, remote or delete; NAME@TARGET=local|remote for a platform value (repeatable, needs --conflict) | `[]` |
| `--conflict <conflict_id>` | the conflict_id a conflict returned: binds --resolve to what was seen | |

JSON support: yes (`--json`)

Expand All @@ -674,6 +702,8 @@ capy remove <vars...> [options]
| `--pr` | create a PR with the keep.lock change (answers the prompt) | |
| `--no-pr` | do not create a PR with the keep.lock change | |
| `--pr-base <branch>` | base branch for the PR (answers the prompt) | |
| `--resolve <NAME=pinned|local|remote|delete>` | answer a difference from Capy: pinned, local, remote or delete; NAME@TARGET=local|remote for a platform value (repeatable, needs --conflict) | `[]` |
| `--conflict <conflict_id>` | the conflict_id a conflict returned: binds --resolve to what was seen | |

JSON support: yes (`--json`)

Expand Down Expand Up @@ -705,6 +735,8 @@ capy connect [provider] [options]
| `-y, --yes` | dokploy import/discover: skip the confirmation prompt (import: --overwrite's clear/replace/import ask) — discover never prompts and needs --yes to write | |
| `--overwrite` | dokploy import: set the branch's vars to EXACTLY Dokploy's set — clear names not in Dokploy, replace differing values, import new ones | |
| `--environment <names>` | dokploy discover: restrict the plan to these Dokploy environment names, comma-separated (e.g. staging,preview) | |
| `--resolve <NAME=pinned|local|remote|delete>` | answer a difference from Capy: pinned, local, remote or delete; NAME@TARGET=local|remote for a platform value (repeatable, needs --conflict) | `[]` |
| `--conflict <conflict_id>` | the conflict_id a conflict returned: binds --resolve to what was seen | |

JSON support: yes (`--json`)

Expand All @@ -726,6 +758,8 @@ capy rotate [var] [options]
| `--skip-prompts` | alias for --yes | |
| `--non-tty` | never prompt; resolve choices from flags or fail fast (agents/CI) | |
| `--provider <name>` | integration to promote an unmanaged var through (non-interactive) | |
| `--resolve <NAME=pinned|local|remote|delete>` | answer a difference from Capy: pinned, local, remote or delete; NAME@TARGET=local|remote for a platform value (repeatable, needs --conflict) | `[]` |
| `--conflict <conflict_id>` | the conflict_id a conflict returned: binds --resolve to what was seen | |

JSON support: no

Expand Down Expand Up @@ -830,6 +864,7 @@ Every refusal carries a stable `code` — branch on it, never on message text.
- `PROJECT_NOT_FOUND`
- `BRANCH_NOT_FOUND`
- `NO_ACTIVE_BRANCH`
- `BRANCH_STATE_INCONSISTENT`
- `SNAPSHOT_NOT_FOUND`
- `NO_SECRETS`
- `DEPLOY_TOKEN_NOT_FOUND`
Expand Down Expand Up @@ -878,8 +913,13 @@ Every refusal carries a stable `code` — branch on it, never on message text.
- `PAIR_NO_KEYS`
- `PAIR_LOCAL_KEY_CONFLICT`
- `VAR_NOT_FOUND`
- `REMOVE_LOCAL_DRIFT`
- `REMOVE_NEEDS_TTY`
- `SECRETS_STALE_BASE`
- `SECRETS_CONFLICT`
- `SECRETS_CONFLICT_STALE`
- `SECRETS_RESOLVE_INVALID`
- `SECRETS_RESOLVE_NEEDS_CONFLICT`
- `REMOVE_LOCAL_DRIFT`
- `KEEP_PR_NOT_GIT_REPO`
- `KEEP_PR_NO_GITHUB_REMOTE`
- `KEEP_PR_GH_UNAVAILABLE`
Expand Down Expand Up @@ -931,3 +971,4 @@ Every refusal carries a stable `code` — branch on it, never on message text.

- **JSON**: Pass --json on any command whose supportsJson is true and parse stdout as JSON; prose (progress, prompts, errors) goes to stderr so stdout stays pure JSON.
- **Codes**: Branch only on the machine-readable `code` field (from --json output or a CapyError), never on human-readable message text — messages may be reworded without notice.
- **Conflicts**: Every command that writes from a folder (edit, add, remove, connect, rotate, push, checkout, org, deploy, deploy revoke, deploy targets-remove) runs bare `capy` first; `checkout -b` is the one exception (a new branch carries the uncommitted values of the folder). Off a terminal, if the folder and Capy differ on any variable, nothing is written, the exit code is 3, and stdout is {ok:false, code:"SECRETS_CONFLICT", conflict_id, conflicts:[{name, project, branch, type, pinned, local, remote}]} (fingerprints only, never values; a Dokploy value has {name, target, pinned, local, remote}). Ask the person, then rerun the same command with --resolve NAME=pinned|local|remote|delete for every row (NAME@TARGET=local|remote for a Dokploy value) and --conflict <conflict_id>. A state that moved since is SECRETS_CONFLICT_STALE with the fresh rows. There is no resolve-all.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@capysc/cli",
"version": "0.9.11",
"version": "0.9.12",
"description": "Capy CLI - SecretOps for the AI age",
"main": "dist/index.js",
"bin": {
Expand Down
21 changes: 19 additions & 2 deletions src/commands/addCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { CapyError, ERROR_CODES } from '../types';
import { resolveContext, writeAndSync, type ResolvedContext } from './connectors/shared';
import { DRY_RUN_UNSUPPORTED_MESSAGES, MAX_PIPED_BYTES, readPipedValue, refuseDryRunUnsupported, refuseInvalidName, refusePiped } from './pipedValue';
import { runPipedWrite, variableExists } from './pipedWrite';
import { conflictFlagsOrExit, type RawConflictFlags } from '../sync/conflicts';
import { gateFirst, type GateOptions } from '../sync/gate';
import {
recordsForWrite,
refuseBadPrFlags,
Expand All @@ -26,10 +28,17 @@ export interface AddOpts {
pr?: PrFlags;
/** The global `--dry-run`: a piped add previews; an interactive add refuses (`DRY_RUN_UNSUPPORTED`). */
dryRun?: boolean;
/** `--resolve` / `--conflict` as typed (CAP-717): they answer the differences bare `capy` finds first. */
conflictFlags?: RawConflictFlags;
}

const VAR_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;

/** What bare `capy` needs to run as this command's first step. */
function gateOptionsOf(opts: AddOpts, devMode: boolean): GateOptions {
return { devMode, json: opts.json === true, nonTty: opts.nonTty, conflictFlags: opts.conflictFlags, dryRun: opts.dryRun === true };
}

/**
* Piped mode is "a value arrives on stdin". It is decided by stdin alone.
* `--non-tty` with a real terminal on stdin is NOT piped (nothing was piped,
Expand Down Expand Up @@ -89,7 +98,12 @@ export class AddCommand {
}
}

const ctx = await resolveContext({ devMode: this.devMode });
// Bare `capy` runs before anything else: the folder and Capy must agree before this writes (docs/conflict-resolution.md).
const gate = gateOptionsOf(opts, this.devMode);
conflictFlagsOrExit(opts.conflictFlags ?? {}, opts.json === true);
await gateFirst(gate);

const ctx = await resolveContext({ devMode: this.devMode, gate });
const push = opts.noPush !== true;

const existing = names.filter((n) => n in ctx.localPlaintext);
Expand Down Expand Up @@ -137,6 +151,8 @@ export class AddCommand {
const [name] = names;
// The argument is not echoed: a `NAME=value` typed by mistake must not be printed back.
if (!VAR_RE.test(name)) return refuseInvalidName(json);
// A bad flag is refused before stdin is read or anything is authenticated.
conflictFlagsOrExit(opts.conflictFlags ?? {}, json);

const piped = await readPipedValue(process.stdin, MAX_PIPED_BYTES);
if (!piped.ok) return refusePiped(json, piped.code, piped.error);
Expand All @@ -148,7 +164,8 @@ export class AddCommand {
command: 'add',
pr: opts.pr,
dryRun: opts.dryRun === true,
gate: (ctx) =>
gate: gateOptionsOf(opts, this.devMode),
refuseWhen: (ctx) =>
variableExists(ctx, name) && opts.force !== true
? {
code: ERROR_CODES.ADD_VAR_EXISTS,
Expand Down
Loading
Loading