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
26 changes: 18 additions & 8 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ sevalla auth status --json
# → { "logged_in": true, "source": "environment" }
```

`auth status` checks the token with the API. A revoked or expired token gives `"logged_in": false, "token_rejected": true`.

## Output Format

| Context | Default output |
Expand All @@ -22,48 +24,56 @@ sevalla auth status --json

Always parse JSON output. Never parse table output.

List commands return one page (25 items by default, `--per-page` up to 100). When more pages exist, stderr says so, e.g. `Showing 1-25 of 60. Use --page 2 for the next page.` Check it before concluding that a resource doesn't exist.

## Command Discovery

```bash
sevalla schema # Full command tree as JSON
sevalla schema apps.create # Single command metadata
```

The schema includes arguments, options, descriptions, and defaults.
The schema includes arguments, options, descriptions, and defaults. For options, `mandatory: true` means the option must be passed; `takesValue: true` means it takes a value (`--name <name>`) rather than being a boolean switch.

## Dry Run

Mutation commands (`create`, `update`, `delete`, action commands) support `--dry-run`:
Most mutation commands (`create`, `update`, `delete`, action commands) support `--dry-run`; `sevalla schema <command>` lists it when they do. A command without it rejects the flag and does nothing:

```bash
sevalla apps create --name test --project p1 --cluster c1 --dry-run
sevalla apps create --name test --cluster c1 --source dockerImage --docker-image nginx:latest --dry-run
# → { "dry_run": true, "command": "create", "options": { ... } }
```

No API call is made. Use this to validate inputs before executing.
No API call is made. Use this to validate inputs before executing. Passwords, secrets and env var values are masked in the output.

## Raw JSON Body

Commands with `--data` accept a raw JSON payload, bypassing flag-based input:
Commands with `--data` accept a raw JSON payload instead of flag-based input. `--data` can't be combined with the body flags; put every field in the JSON:

```bash
sevalla apps create --data '{"name":"test","project_id":"p1","cluster_id":"c1"}'
sevalla apps create --data '{"display_name":"test","cluster_id":"c1","source":"dockerImage","docker_image":"nginx:latest"}'
```

Combine with `--dry-run` to preview:

```bash
sevalla apps create --data '{"name":"test"}' --dry-run
sevalla apps create --data '{"display_name":"test"}' --dry-run
```

## Destructive Operations

All destructive commands require `--confirm`:
The CLI never prompts. Commands that delete data or can't be undone fail unless `--confirm` is passed: every `delete`/`remove`, and `apps suspend`, `databases suspend`, `databases reset-password`, `databases backups restore`, `api-keys rotate`, `object-storage rotate-credentials`, `webhooks roll-secret`, `apps deployment-hook regenerate`, `apps deployment-hook disable`.

```bash
sevalla apps delete <id> --confirm
```

`--dry-run` works without `--confirm`.

## Secrets

Environment variable values are hidden by default (`env-vars list` shows keys only; pass `--show-values` to include them), and `env-vars create`/`update` don't echo the value back.

## Error Format

Errors are output as JSON when in JSON mode:
Expand Down
22 changes: 12 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ sevalla apps create --name myapp --source privateGit --cluster <id>
sevalla apps update <id> --display-name "My App"
sevalla apps delete <id>
sevalla apps activate <id>
sevalla apps suspend <id>
sevalla apps suspend <id> --confirm
sevalla apps clone <id> --display-name "Clone" --cluster <id>
sevalla apps purge-cache <id>
sevalla apps cdn-toggle <id>
Expand All @@ -98,6 +98,8 @@ sevalla apps cdn-toggle <id>
sevalla apps deployments list --app-id <id>
sevalla apps deployments get <deployment-id> --app-id <id>
sevalla apps deployments trigger <app-id> --branch main
sevalla apps deployments trigger <app-id> --commit <sha> --skip-build-cache
sevalla apps deployments trigger <app-id> --restart
sevalla apps deployments rollback <app-id>
sevalla apps deployments cancel <deployment-id> --app-id <id>
sevalla apps deployments logs <deployment-id> --app-id <id>
Expand Down Expand Up @@ -142,7 +144,7 @@ sevalla apps domains refresh-status <app-id> <domain-id>
### Application Environment Variables

```bash
sevalla apps env-vars list --app-id <id>
sevalla apps env-vars list --app-id <id> # values hidden; add --show-values
sevalla apps env-vars create <app-id> --key MY_VAR --value "my value"
sevalla apps env-vars update <env-var-id> --app-id <id> --value "new value"
sevalla apps env-vars delete <app-id> <env-var-id>
Expand Down Expand Up @@ -196,8 +198,8 @@ sevalla apps private-ports delete <app-id> <private-port-id>
```bash
sevalla apps deployment-hook get <app-id>
sevalla apps deployment-hook enable <app-id>
sevalla apps deployment-hook disable <app-id>
sevalla apps deployment-hook regenerate <app-id>
sevalla apps deployment-hook disable <app-id> --confirm
sevalla apps deployment-hook regenerate <app-id> --confirm
```

### Databases
Expand All @@ -210,8 +212,8 @@ sevalla databases create --name mydb --type postgresql --db-version 16 \
sevalla databases update <id> --display-name "My DB"
sevalla databases delete <id>
sevalla databases activate <id>
sevalla databases suspend <id>
sevalla databases reset-password <id> --new-password <password>
sevalla databases suspend <id> --confirm
sevalla databases reset-password <id> --new-password <password> --confirm
```

### Database Backups
Expand All @@ -220,7 +222,7 @@ sevalla databases reset-password <id> --new-password <password>
sevalla databases backups list --db-id <id>
sevalla databases backups create <db-id> --name nightly
sevalla databases backups delete <db-id> <backup-id>
sevalla databases backups restore <db-id> <backup-id>
sevalla databases backups restore <db-id> <backup-id> --confirm
```

### Database Connections
Expand Down Expand Up @@ -441,7 +443,7 @@ sevalla webhooks create --endpoint https://example.com/hook --events "deployment
sevalla webhooks update <id>
sevalla webhooks delete <id>
sevalla webhooks toggle <id>
sevalla webhooks roll-secret <id>
sevalla webhooks roll-secret <id> --confirm
sevalla webhooks deliveries list --webhook-id <id>
sevalla webhooks deliveries get <delivery-id> --webhook-id <id>
```
Expand All @@ -455,7 +457,7 @@ sevalla api-keys create --name "My Key"
sevalla api-keys update <id>
sevalla api-keys delete <id>
sevalla api-keys toggle <id>
sevalla api-keys rotate <id>
sevalla api-keys rotate <id> --confirm
sevalla api-keys validate
```

Expand Down Expand Up @@ -497,7 +499,7 @@ Every command accepts these flags:
|--------|-------------|
| `--json` | Output as JSON (for scripting and CI/CD) |
| `--api-url <url>` | Override API base URL |
| `--confirm` | Skip confirmation on destructive operations |
| `--confirm` | Required by commands that delete data or can't be undone (suspend, restore, reset or rotate credentials) |
| `--version` | Show version |
| `--help` | Show help |

Expand Down
1 change: 1 addition & 0 deletions src/commands/api-keys/rotate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { makeActionCommand } from '../../helpers/command-factory.ts';
export const apiKeysRotateCommand = makeActionCommand({
name: 'rotate',
description: 'Rotate an API key',
confirm: 'Rotating invalidates the current token (immediately, or after --ttl hours).',
options: [{ flags: '--ttl <hours>', description: 'Hours before old token expires (default: 0)' }],
apiCall: (client, id, opts) =>
client.post(`/api-keys/${id}/rotate`, {
Expand Down
1 change: 1 addition & 0 deletions src/commands/apps/deployment-hook/disable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { makeActionCommand } from '../../../helpers/command-factory.ts';
export const deploymentHookDisableCommand = makeActionCommand({
name: 'disable',
description: 'Disable deployment hook',
confirm: 'Disabling invalidates the current deployment hook URL; re-enabling gives a new one.',
idArg: 'app-id',
apiCall: (client, id) => client.delete(`/applications/${id}/deployment-hook`),
successMessage: 'Deployment hook disabled.',
Expand Down
1 change: 1 addition & 0 deletions src/commands/apps/deployment-hook/regenerate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { makeActionCommand } from '../../../helpers/command-factory.ts';
export const deploymentHookRegenerateCommand = makeActionCommand({
name: 'regenerate',
description: 'Regenerate deployment hook URL',
confirm: 'Regenerating invalidates the current deployment hook URL.',
idArg: 'app-id',
apiCall: (client, id) => client.put(`/applications/${id}/deployment-hook`),
successMessage: 'Deployment hook regenerated.',
Expand Down
13 changes: 11 additions & 2 deletions src/commands/apps/deployments/cancel.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { Command } from 'commander';
import { createClient } from '../../../client/api-client.ts';
import { handleError } from '../../../errors/handler.ts';
import { jsonOption, resolveJsonMode } from '../../../helpers/flags.ts';
import { dryRunOption, jsonOption, resolveJsonMode } from '../../../helpers/flags.ts';
import { validateId } from '../../../helpers/validate.ts';
import { printJson, printSuccess } from '../../../output/formatter.ts';
import { startSpinner, stopSpinner } from '../../../output/spinner.ts';

Expand All @@ -10,12 +11,20 @@ export const deploymentsCancelCommand = new Command('cancel')
.requiredOption('--app-id <id>', 'Application ID')
.argument('<deployment-id>', 'Deployment ID')
.addOption(jsonOption())
.addOption(dryRunOption())
.action(async (deploymentId: string, opts: Record<string, unknown>) => {
const client = createClient({ apiUrl: opts['apiUrl'] as string | undefined });
const json = resolveJsonMode(opts);
try {
validateId(opts['appId'] as string, 'app-id');
validateId(deploymentId, 'deployment-id');
const path = `/applications/${opts['appId']}/deployments/${deploymentId}/cancel`;
if (opts['dryRun']) {
printJson({ dry_run: true, command: 'cancel', method: 'POST', path });
return;
}
if (!json) startSpinner('Cancelling deployment...');
await client.post(`/applications/${opts['appId']}/deployments/${deploymentId}/cancel`);
await client.post(path);
stopSpinner();
if (json) {
printJson({ success: true });
Expand Down
16 changes: 15 additions & 1 deletion src/commands/apps/deployments/trigger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,24 @@ export const deploymentsTriggerCommand = makeActionCommand({
name: 'trigger',
description: 'Trigger a new deployment',
idArg: 'app-id',
options: [{ flags: '--branch <branch>', description: 'Branch to deploy' }],
options: [
{ flags: '--branch <branch>', description: 'Branch to deploy' },
{
flags: '--commit <sha>',
description: 'Full 40-character SHA of the commit to deploy (default: latest on the branch)',
},
{ flags: '--skip-build-cache', description: 'Rebuild every layer instead of using the cache' },
{
flags: '--restart',
description: 'Redeploy the last successful build without rebuilding',
},
],
apiCall: (client, id, opts) => {
const body: Record<string, unknown> = {};
if (opts['branch']) body['branch'] = opts['branch'];
if (opts['commit']) body['commit_sha'] = opts['commit'];
if (opts['skipBuildCache']) body['skip_build_cache'] = true;
if (opts['restart']) body['is_restart'] = true;
return client.post(`/applications/${id}/deployments`, body);
},
successMessage: 'Deployment triggered.',
Expand Down
13 changes: 8 additions & 5 deletions src/commands/apps/env-vars/create.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { makeActionCommand } from '../../../helpers/command-factory.ts';
import { omitValue } from '../../../helpers/secrets.ts';

export const envVarsCreateCommand = makeActionCommand({
export const envVarsCreateCommand = makeActionCommand<Record<string, unknown>>({
name: 'create',
description: 'Create an environment variable',
idArg: 'app-id',
Expand All @@ -9,9 +10,11 @@ export const envVarsCreateCommand = makeActionCommand({
{ flags: '--value <value>', description: 'Variable value', required: true },
],
apiCall: (client, id, opts) =>
client.post(`/applications/${id}/env-vars`, {
key: opts['key'],
value: opts['value'],
}),
client
.post<Record<string, unknown>>(`/applications/${id}/env-vars`, {
key: opts['key'],
value: opts['value'],
})
.then(omitValue),
successMessage: 'Environment variable created.',
});
11 changes: 4 additions & 7 deletions src/commands/apps/env-vars/list.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,10 @@
import { makeListCommand } from '../../../helpers/command-factory.ts';
import { envVarColumns, listEnvVars, showValuesOption } from '../../../helpers/secrets.ts';

export const envVarsListCommand = makeListCommand({
name: 'list',
description: 'List environment variables',
columns: [
{ header: 'ID', key: 'id' },
{ header: 'Key', key: 'key' },
{ header: 'Value', key: 'value' },
],
apiCall: (client, opts) => client.get(`/applications/${opts['appId']}/env-vars`),
columns: envVarColumns,
apiCall: (client, opts) => listEnvVars(client, `/applications/${opts['appId']}/env-vars`, opts),
parentIdFlag: { name: 'app-id', description: 'Application ID' },
});
}).addOption(showValuesOption());
22 changes: 19 additions & 3 deletions src/commands/apps/env-vars/update.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { Command } from 'commander';
import { createClient } from '../../../client/api-client.ts';
import { handleError } from '../../../errors/handler.ts';
import { jsonOption, resolveJsonMode } from '../../../helpers/flags.ts';
import { dryRunOption, jsonOption, resolveJsonMode } from '../../../helpers/flags.ts';
import { maskSecrets, omitValue } from '../../../helpers/secrets.ts';
import { validateId } from '../../../helpers/validate.ts';
import { printJson, printSuccess } from '../../../output/formatter.ts';
import { startSpinner, stopSpinner } from '../../../output/spinner.ts';

Expand All @@ -12,18 +14,32 @@ export const envVarsUpdateCommand = new Command('update')
.option('--key <key>', 'Variable key')
.option('--value <value>', 'Variable value')
.addOption(jsonOption())
.addOption(dryRunOption())
.action(async (envVarId: string, opts: Record<string, unknown>) => {
const client = createClient({ apiUrl: opts['apiUrl'] as string | undefined });
const json = resolveJsonMode(opts);
try {
validateId(opts['appId'] as string, 'app-id');
validateId(envVarId, 'env-var-id');
const body: Record<string, unknown> = {};
if (opts['key']) body['key'] = opts['key'];
if (opts['value']) body['value'] = opts['value'];
const path = `/applications/${opts['appId']}/env-vars/${envVarId}`;
if (opts['dryRun']) {
printJson({
dry_run: true,
command: 'update',
method: 'PUT',
path,
body: maskSecrets(body),
});
return;
}
if (!json) startSpinner('Updating...');
const result = await client.put(`/applications/${opts['appId']}/env-vars/${envVarId}`, body);
const result = await client.put<Record<string, unknown>>(path, body);
stopSpinner();
if (json) {
printJson(result);
printJson(omitValue(result));
} else {
printSuccess('Environment variable updated.');
}
Expand Down
18 changes: 16 additions & 2 deletions src/commands/apps/get.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,31 @@
import { makeGetCommand } from '../../helpers/command-factory.ts';
import type { PaginatedResponse } from '../../client/types.ts';

type Item = Record<string, unknown>;

export const appsGetCommand = makeGetCommand({
name: 'get',
description: 'Get application details',
displayFields: (item: Record<string, unknown>) => ({
displayFields: (item: Item) => ({
ID: item['id'],
Name: item['display_name'] ?? item['name'],
Status: item['status'],
Source: item['source'],
Type: item['type'],
'Build Type': item['build_type'],
Suspended: item['is_suspended'],
'Primary Domain': item['primary_domain'],
'Created At': item['created_at'],
}),
apiCall: (client, id) => client.get(`/applications/${id}`),
// The application resource has no address, so the primary domain comes from its domain list
apiCall: async (client, id) => {
const [app, domains] = await Promise.all([
client.get<Item>(`/applications/${id}`),
client.get<PaginatedResponse<Item>>(`/applications/${id}/domains`, { limit: 100 }),
]);
const primary =
domains.data.find((d) => d['is_primary']) ??
domains.data.find((d) => d['type'] === 'SYSTEM' && d['is_enabled']);
return { ...app, primary_domain: primary?.['name'] ?? null };
},
});
Loading
Loading