Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
040248a
feat(deploy): vercel target API mode (projectId + system-store token,…
cvince Oct 1, 2026
ce83eeb
feat(connect): capy connect vercel — one-time import of Vercel env va…
cvince Oct 1, 2026
ee47651
merge: Vercel deploy target API mode into the Vercel import branch
cvince Oct 1, 2026
5b93f22
feat(connect): capy connect vercel --create-token (team-scoped token …
cvince Oct 1, 2026
8f579e7
feat(deploy): Vercel deploy setup by dashboard link; connect vercel -…
cvince Oct 2, 2026
b882c06
Merge origin/main into vince/vercel-deploy-setup
cvince Nov 2, 2026
28a804a
feat(deploy): capy deploy vercel --discover; connect vercel no longer…
cvince Nov 2, 2026
5b6dceb
fix(deploy): vercel --discover reads the connector token, like dokplo…
cvince Nov 2, 2026
21a5437
Merge remote-tracking branch 'origin/main' into vince/vercel-deploy-s…
cvince Nov 2, 2026
e5462e7
feat(edit): capy edit NAME on a terminal asks for the value like capy…
cvince Nov 2, 2026
183c3b1
fix(vercel): say why a token stops a run, and confirm a saved token
cvince Nov 3, 2026
dcbfcfb
Merge branch 'vince/cap-720-capy-edit-name-prompt' into vince/vercel-…
cvince Nov 3, 2026
e28dd8c
docs: regenerate the CLI reference
cvince Nov 3, 2026
8cc46c5
feat(vercel): values Vercel will not give back become placeholders
cvince Nov 3, 2026
a144072
feat(vercel): deploy never overwrites with a placeholder
cvince Nov 3, 2026
615a5b6
feat(vercel): report names Vercel will not return, with a Capy value,…
cvince Nov 3, 2026
b064d2a
feat(connect): provider-neutral discovery steps; an empty or placehol…
cvince Nov 3, 2026
2e8aa5c
test: removing the last variable pushes the empty-branch blob
cvince Nov 3, 2026
5e4b98e
fix(vercel): a 404 on one variable's value becomes a placeholder, not…
cvince Nov 5, 2026
3220f2d
feat(vercel): judge a preview's git branch from deployments, scoped v…
cvince Nov 6, 2026
4bf22a5
fix(vercel): a preview's branch is a long-lived branch; deployments o…
cvince Nov 6, 2026
3fac816
release: Capy CLI v0.9.14
cvince Nov 6, 2026
2eb7e37
feat(secrets): Ctrl+S cycles the list sort (CAP-731)
cvince Nov 6, 2026
f28ab0e
docs(cli): regenerate the CLI reference for 0.9.14
cvince Nov 6, 2026
88c3f4c
feat(secrets): Ctrl+S opens a sort menu; choosing a sort goes back to…
cvince Nov 6, 2026
1f3f4e0
test(vercel): compare the request host, not a URL substring
cvince Nov 6, 2026
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
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.9.13
0.9.14
131 changes: 122 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.13`. Generated from `capy help --json`.
Version `0.9.14`. Generated from `capy help --json`.

## Commands

Expand Down Expand Up @@ -34,15 +34,15 @@ JSON support: yes (`--json`)

## `capy edit`

Inspect and edit secrets in an interactive TUI, or set one variable from a piped value
Inspect and edit secrets in an interactive TUI, or set one variable from a hidden prompt or a piped value

```
capy edit [name] [options]
```

| Option | Description | Default |
|---|---|---|
| `--no-push` | piped value: write .env only; do not push to Capy | |
| `--no-push` | with a name: write .env only; do not push to Capy | |
| `--json` | emit machine-readable JSON instead of the human UI (piped value) | |
| `--non-tty` | treat stdin as not a terminal; never prompt (agents/CI) | |
| `--pr` | create a PR with the keep.lock change (answers the prompt) | |
Expand Down Expand Up @@ -127,10 +127,11 @@ capy deploy [target] [options]
| `--scope <scope>` | gh-actions: "repo" or "env" | |
| `--env-name <name>` | gh-actions: env name when --scope env | |
| `--no-deploy` | write and verify the target, but skip the platform deploy/redeploy (target mode) | |
| `--replace-sensitive` | vercel: allow replacing a value Vercel holds as sensitive, for the first time for a target, without asking | |
| `--json` | describe the route (unanswered stops + any known branch problem) as JSON instead of travelling it | |
| `--discover` | dokploy: find the services that match Capy projects and print JSON (never prompts) | |
| `--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) | |
| `--discover` | dokploy/vercel: find the services that match Capy projects and print JSON (never prompts) | |
| `--plan <file>` | dokploy/vercel --discover: the plan file to check (see schemas.deploy_dokploy_plan and schemas.deploy_vercel_plan in `capy help --json`) | |
| `--confirm <plan_id>` | dokploy/vercel --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 | |
Expand Down Expand Up @@ -728,13 +729,15 @@ capy connect [provider] [options]
| `--base-url <url>` | dokploy import: dashboard URL | |
| `--application <id>` | dokploy import: Application id (mutually exclusive with --compose) | |
| `--compose <id>` | dokploy import: Compose service id (mutually exclusive with --application) | |
| `--token-env <name>` | dokploy import: env var holding the API token | |
| `--token-env <name>` | dokploy/vercel import: env var holding the API token | |
| `--project <project>` | vercel import: Vercel project id or name (under --discover: the project that wins a mapping collision) | |
| `--team <team>` | vercel import/discover: Vercel team slug or id (default: the token's only team) | |
| `--json` | emit machine-readable JSON instead of the human UI (import connectors) | |
| `--dry-run` | dokploy import/discover: preview the plan only — resolve settings + read Dokploy, write/push nothing | |
| `--discover` | dokploy: find every Dokploy service matching a repo under cwd, instead of one named --application/--compose | |
| `--discover` | dokploy/vercel: find every Dokploy service / Vercel project matching a repo under cwd, instead of one named --application/--compose/--project | |
| `-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) | |
| `--environment <names>` | dokploy/vercel discover: restrict the plan to these environment names, comma-separated (e.g. staging,preview); vercel import: which Vercel environment to read (default: the current branch's name) | |
| `--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 | |

Expand Down Expand Up @@ -845,6 +848,72 @@ JSON Schemas of the files commands read, as published under `schemas` in `capy h
}
```

### `deploy_vercel_plan`

```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "capy deploy vercel --discover plan",
"type": "object",
"additionalProperties": false,
"required": [
"version",
"entries"
],
"properties": {
"version": {
"const": 1
},
"entries": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"project_id",
"branch",
"service_id",
"git_branch",
"vars"
],
"properties": {
"project_id": {
"type": "string",
"minLength": 1,
"description": "Capy project id"
},
"branch": {
"type": "string",
"minLength": 1,
"description": "Capy branch the target ships from"
},
"service_id": {
"type": "string",
"minLength": 1,
"description": "Vercel environment id from the discovery facts (a project plus one of its environments)"
},
"git_branch": {
"type": "string",
"minLength": 1,
"description": "git branch the Vercel project deploys from (the CI deploy PR base)"
},
"vars": {
"type": "array",
"minItems": 1,
"items": {
"type": "string",
"pattern": "^[A-Za-z_][A-Za-z0-9_]*$"
},
"description": "Variable names the target delivers; each must exist on the Capy branch"
}
}
}
}
}
}
```

## Error codes

Every refusal carries a stable `code` — branch on it, never on message text.
Expand Down Expand Up @@ -931,6 +1000,50 @@ Every refusal carries a stable `code` — branch on it, never on message text.
- `CI_DEPLOY_TARGETS_RECORD_FAILED`
- `DEPLOY_STALE_KEEP`
- `DEPLOY_TOKEN_UNTRACKED`
- `VERCEL_TOKEN_MISSING`
- `VERCEL_CONNECTOR_KEY_MISSING`
- `VERCEL_AUTH_FAILED`
- `VERCEL_API_ERROR`
- `VERCEL_RATE_LIMITED`
- `VERCEL_TEAM_REQUIRED`
- `VERCEL_TEAM_NOT_FOUND`
- `VERCEL_PROJECT_REQUIRED`
- `VERCEL_PROJECT_NOT_FOUND`
- `VERCEL_ENVIRONMENT_REQUIRED`
- `VERCEL_ENVIRONMENT_NOT_FOUND`
- `VERCEL_CONFIRMATION_REQUIRED`
- `VERCEL_MAPPING_COLLISION`
- `VERCEL_TARGET_KEY_MISSING`
- `VERCEL_TOKEN_REJECTED`
- `VERCEL_NO_ACCESS`
- `VERCEL_NETWORK_ERROR`
- `VERCEL_VERIFY_MISMATCH`
- `VERCEL_OPTIONS_INVALID`
- `VERCEL_SPLIT_NOT_RESTORED`
- `VERCEL_URL_INVALID`
- `VERCEL_SENSITIVE_REPLACE_UNCONFIRMED`
- `VERCEL_PROJECT_NOT_GITHUB`
- `VERCEL_ENV_LIST_TRUNCATED`
- `VERCEL_PREVIEW_DEPLOYMENTS_UNAVAILABLE`
- `CAPY_VALUE_KEPT`
- `CONNECTOR_PUSH_FAILED`
- `CONNECTOR_FOLDER_NOT_INITIALIZED`
- `CONNECTOR_PROJECT_LOOKUP_FAILED`
- `CONNECTOR_PROJECT_NAME_EXISTS`
- `CONNECTOR_PROJECT_NOT_FOUND`
- `CONNECTOR_INIT_FAILED`
- `CONNECTOR_PROJECT_NAME_TOO_LONG`
- `CONNECTOR_KEY_RESOLUTION_FAILED`
- `CONNECTOR_FOLDER_DIRTY`
- `CONNECTOR_BRANCH_PROTECTED`
- `CONNECTOR_KEEP_MISSING`
- `CONNECTOR_CHECKOUT_FAILED`
- `CONNECTOR_REMOTE_UNREADABLE`
- `CONNECTOR_COMMIT_WOULD_MIX`
- `CONNECTOR_COMMIT_BRANCH_INVALID`
- `CONNECTOR_COMMIT_BRANCH_EXISTS`
- `CONNECTOR_GIT_IDENTITY_MISSING`
- `CONNECTOR_COMMIT_FAILED`
- `EDIT_NEEDS_TTY`
- `EDIT_STDIN_LOCAL_ONLY`
- `STDIN_EMPTY`
Expand Down
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.13",
"version": "0.9.14",
"description": "Capy CLI - SecretOps for the AI age",
"main": "dist/index.js",
"bin": {
Expand Down
157 changes: 93 additions & 64 deletions src/commands/addCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
refuseBadPrFlags,
reportKeepLockHuman,
runKeepLockPrStep,
type KeepLockCommandName,
type PrFlags,
} from './keepLockPr';

Expand Down Expand Up @@ -74,6 +75,93 @@ async function writeInOrder(
return writeInOrder({ ...ctx, localPlaintext: { ...ctx.localPlaintext, [name]: value } }, rest, push);
}

/** What the prompted set needs beyond the flags: who is asking, and what a dry run says. */
export interface PromptedSetArgs {
readonly names: readonly string[];
readonly opts: AddOpts;
readonly devMode: boolean;
/** Which command is writing: named in the PR body. */
readonly command: KeepLockCommandName;
/** The `DRY_RUN_UNSUPPORTED` sentence for this command. */
readonly dryRunMessage: string;
}

/**
* The hidden terminal prompt and the one write path behind it, shared by `capy add NAME`
* and `capy edit NAME` on a terminal. Asks for each value (masked), writes, syncs, runs the
* keep.lock PR step. With `opts.force` an existing name is overwritten without a question.
*/
export async function runPromptedSet(args: PromptedSetArgs): Promise<void> {
const { names, opts, devMode, command, dryRunMessage } = args;

// The prompts would really save: a dry run never runs for real. Before any prompt.
if (opts.dryRun === true) return refuseDryRunUnsupported(opts.json === true, dryRunMessage);

if (names.length === 0) {
throw new CapyError('No variable name given.', ERROR_CODES.INVALID_FORMAT);
}
for (const name of names) {
if (!VAR_RE.test(name)) {
throw new CapyError(`"${name}" is not a valid environment variable name.`, ERROR_CODES.INVALID_FORMAT);
}
}

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

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

const existing = names.filter((n) => n in ctx.localPlaintext);
if (existing.length > 0 && !opts.force && !opts.nonTty) {
const inquirer = (await import('inquirer')).default;
const { ok } = await inquirer.prompt([
{ type: 'confirm', name: 'ok', message: `${existing.join(', ')} already exist(s). Overwrite?`, default: false },
]);
if (!ok) {
console.log('Aborted.');
return;
}
}

const savedNames = await collectViaTerminal(ctx, names, opts, push);

const where = push ? ` and synced to ${ctx.branch}` : ' (.env only — not pushed)';
console.log(`✓ Saved ${savedNames.length} variable(s): ${savedNames.join(', ')}${where}.`);

const outcome = await runKeepLockPrStep({
command,
cwd: process.cwd(),
records: recordsForWrite(ctx.keep, ctx.pm.readKeepFile(), ctx.branch, savedNames),
localKeep: ctx.keep,
flags: opts.pr ?? {},
json: opts.json === true,
nonTty: opts.nonTty,
});
reportKeepLockHuman(outcome, { successTo: 'stdout', noteUnanswered: false });
}

/** The hidden terminal prompt, one value per name. */
async function collectViaTerminal(
ctx: ResolvedContext,
names: readonly string[],
opts: AddOpts,
push: boolean,
): Promise<string[]> {
if (opts.nonTty) {
throw new CapyError(
// COPY-FLAG
'Non-interactive add reads the value from stdin: <cmd> | capy add NAME',
ERROR_CODES.INVALID_FORMAT,
);
}
const pairs = await promptForValues(names);
await writeInOrder(ctx, pairs, push);
return pairs.map((p) => p.name);
}

export class AddCommand {
constructor(private readonly devMode: boolean = false) {}

Expand All @@ -86,53 +174,13 @@ export class AddCommand {
// `<cmd> | capy add NAME`: the value comes from stdin and nothing prompts.
if (isPipedAdd()) return this.executePiped(names, opts);

// The prompts would really save: a dry run never runs for real. Before any prompt.
if (opts.dryRun === true) return refuseDryRunUnsupported(opts.json === true, DRY_RUN_UNSUPPORTED_MESSAGES.add);

if (names.length === 0) {
throw new CapyError('No variable name given.', ERROR_CODES.INVALID_FORMAT);
}
for (const name of names) {
if (!VAR_RE.test(name)) {
throw new CapyError(`"${name}" is not a valid environment variable name.`, ERROR_CODES.INVALID_FORMAT);
}
}

// 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);
if (existing.length > 0 && !opts.force && !opts.nonTty) {
const inquirer = (await import('inquirer')).default;
const { ok } = await inquirer.prompt([
{ type: 'confirm', name: 'ok', message: `${existing.join(', ')} already exist(s). Overwrite?`, default: false },
]);
if (!ok) {
console.log('Aborted.');
return;
}
}

const savedNames = await this.collectViaTerminal(ctx, names, opts, push);

const where = push ? ` and synced to ${ctx.branch}` : ' (.env only — not pushed)';
console.log(`✓ Saved ${savedNames.length} variable(s): ${savedNames.join(', ')}${where}.`);

const outcome = await runKeepLockPrStep({
return runPromptedSet({
names,
opts,
devMode: this.devMode,
command: 'add',
cwd: process.cwd(),
records: recordsForWrite(ctx.keep, ctx.pm.readKeepFile(), ctx.branch, savedNames),
localKeep: ctx.keep,
flags: opts.pr ?? {},
json: opts.json === true,
nonTty: opts.nonTty,
dryRunMessage: DRY_RUN_UNSUPPORTED_MESSAGES.add,
});
reportKeepLockHuman(outcome, { successTo: 'stdout', noteUnanswered: false });
}

/** `<cmd> | capy add NAME`. One name, one pipe; an existing name needs `--force`. */
Expand Down Expand Up @@ -174,23 +222,4 @@ export class AddCommand {
: undefined,
});
}

/** The hidden terminal prompt, one value per name. */
private async collectViaTerminal(
ctx: ResolvedContext,
names: readonly string[],
opts: AddOpts,
push: boolean,
): Promise<string[]> {
if (opts.nonTty) {
throw new CapyError(
// COPY-FLAG
'Non-interactive add reads the value from stdin: <cmd> | capy add NAME',
ERROR_CODES.INVALID_FORMAT,
);
}
const pairs = await promptForValues(names);
await writeInOrder(ctx, pairs, push);
return pairs.map((p) => p.name);
}
}
Loading
Loading