diff --git a/CONTEXT.md b/CONTEXT.md index be1c9ad..0fb90e0 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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 | @@ -22,6 +24,8 @@ 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 @@ -29,41 +33,47 @@ 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 `) 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 ` 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 --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: diff --git a/README.md b/README.md index 8610c88..86081bc 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,7 @@ sevalla apps create --name myapp --source privateGit --cluster sevalla apps update --display-name "My App" sevalla apps delete sevalla apps activate -sevalla apps suspend +sevalla apps suspend --confirm sevalla apps clone --display-name "Clone" --cluster sevalla apps purge-cache sevalla apps cdn-toggle @@ -98,6 +98,8 @@ sevalla apps cdn-toggle sevalla apps deployments list --app-id sevalla apps deployments get --app-id sevalla apps deployments trigger --branch main +sevalla apps deployments trigger --commit --skip-build-cache +sevalla apps deployments trigger --restart sevalla apps deployments rollback sevalla apps deployments cancel --app-id sevalla apps deployments logs --app-id @@ -142,7 +144,7 @@ sevalla apps domains refresh-status ### Application Environment Variables ```bash -sevalla apps env-vars list --app-id +sevalla apps env-vars list --app-id # values hidden; add --show-values sevalla apps env-vars create --key MY_VAR --value "my value" sevalla apps env-vars update --app-id --value "new value" sevalla apps env-vars delete @@ -196,8 +198,8 @@ sevalla apps private-ports delete ```bash sevalla apps deployment-hook get sevalla apps deployment-hook enable -sevalla apps deployment-hook disable -sevalla apps deployment-hook regenerate +sevalla apps deployment-hook disable --confirm +sevalla apps deployment-hook regenerate --confirm ``` ### Databases @@ -210,8 +212,8 @@ sevalla databases create --name mydb --type postgresql --db-version 16 \ sevalla databases update --display-name "My DB" sevalla databases delete sevalla databases activate -sevalla databases suspend -sevalla databases reset-password --new-password +sevalla databases suspend --confirm +sevalla databases reset-password --new-password --confirm ``` ### Database Backups @@ -220,7 +222,7 @@ sevalla databases reset-password --new-password sevalla databases backups list --db-id sevalla databases backups create --name nightly sevalla databases backups delete -sevalla databases backups restore +sevalla databases backups restore --confirm ``` ### Database Connections @@ -441,7 +443,7 @@ sevalla webhooks create --endpoint https://example.com/hook --events "deployment sevalla webhooks update sevalla webhooks delete sevalla webhooks toggle -sevalla webhooks roll-secret +sevalla webhooks roll-secret --confirm sevalla webhooks deliveries list --webhook-id sevalla webhooks deliveries get --webhook-id ``` @@ -455,7 +457,7 @@ sevalla api-keys create --name "My Key" sevalla api-keys update sevalla api-keys delete sevalla api-keys toggle -sevalla api-keys rotate +sevalla api-keys rotate --confirm sevalla api-keys validate ``` @@ -497,7 +499,7 @@ Every command accepts these flags: |--------|-------------| | `--json` | Output as JSON (for scripting and CI/CD) | | `--api-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 | diff --git a/src/commands/api-keys/rotate.ts b/src/commands/api-keys/rotate.ts index 4ed284d..ff96b07 100644 --- a/src/commands/api-keys/rotate.ts +++ b/src/commands/api-keys/rotate.ts @@ -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 ', description: 'Hours before old token expires (default: 0)' }], apiCall: (client, id, opts) => client.post(`/api-keys/${id}/rotate`, { diff --git a/src/commands/apps/deployment-hook/disable.ts b/src/commands/apps/deployment-hook/disable.ts index 8eb5b20..f087964 100644 --- a/src/commands/apps/deployment-hook/disable.ts +++ b/src/commands/apps/deployment-hook/disable.ts @@ -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.', diff --git a/src/commands/apps/deployment-hook/regenerate.ts b/src/commands/apps/deployment-hook/regenerate.ts index c62386f..20f3673 100644 --- a/src/commands/apps/deployment-hook/regenerate.ts +++ b/src/commands/apps/deployment-hook/regenerate.ts @@ -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.', diff --git a/src/commands/apps/deployments/cancel.ts b/src/commands/apps/deployments/cancel.ts index c179f63..cc619d0 100644 --- a/src/commands/apps/deployments/cancel.ts +++ b/src/commands/apps/deployments/cancel.ts @@ -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'; @@ -10,12 +11,20 @@ export const deploymentsCancelCommand = new Command('cancel') .requiredOption('--app-id ', 'Application ID') .argument('', 'Deployment ID') .addOption(jsonOption()) + .addOption(dryRunOption()) .action(async (deploymentId: string, opts: Record) => { 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 }); diff --git a/src/commands/apps/deployments/trigger.ts b/src/commands/apps/deployments/trigger.ts index 1363d9c..535ef32 100644 --- a/src/commands/apps/deployments/trigger.ts +++ b/src/commands/apps/deployments/trigger.ts @@ -4,10 +4,24 @@ export const deploymentsTriggerCommand = makeActionCommand({ name: 'trigger', description: 'Trigger a new deployment', idArg: 'app-id', - options: [{ flags: '--branch ', description: 'Branch to deploy' }], + options: [ + { flags: '--branch ', description: 'Branch to deploy' }, + { + flags: '--commit ', + 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 = {}; 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.', diff --git a/src/commands/apps/env-vars/create.ts b/src/commands/apps/env-vars/create.ts index 4c869ad..6dcd857 100644 --- a/src/commands/apps/env-vars/create.ts +++ b/src/commands/apps/env-vars/create.ts @@ -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>({ name: 'create', description: 'Create an environment variable', idArg: 'app-id', @@ -9,9 +10,11 @@ export const envVarsCreateCommand = makeActionCommand({ { flags: '--value ', description: 'Variable value', required: true }, ], apiCall: (client, id, opts) => - client.post(`/applications/${id}/env-vars`, { - key: opts['key'], - value: opts['value'], - }), + client + .post>(`/applications/${id}/env-vars`, { + key: opts['key'], + value: opts['value'], + }) + .then(omitValue), successMessage: 'Environment variable created.', }); diff --git a/src/commands/apps/env-vars/list.ts b/src/commands/apps/env-vars/list.ts index 70c1a70..59d9cc1 100644 --- a/src/commands/apps/env-vars/list.ts +++ b/src/commands/apps/env-vars/list.ts @@ -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()); diff --git a/src/commands/apps/env-vars/update.ts b/src/commands/apps/env-vars/update.ts index 9db3ddd..1bbcada 100644 --- a/src/commands/apps/env-vars/update.ts +++ b/src/commands/apps/env-vars/update.ts @@ -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'; @@ -12,18 +14,32 @@ export const envVarsUpdateCommand = new Command('update') .option('--key ', 'Variable key') .option('--value ', 'Variable value') .addOption(jsonOption()) + .addOption(dryRunOption()) .action(async (envVarId: string, opts: Record) => { 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 = {}; 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>(path, body); stopSpinner(); if (json) { - printJson(result); + printJson(omitValue(result)); } else { printSuccess('Environment variable updated.'); } diff --git a/src/commands/apps/get.ts b/src/commands/apps/get.ts index fb7f72d..98672c8 100644 --- a/src/commands/apps/get.ts +++ b/src/commands/apps/get.ts @@ -1,9 +1,12 @@ import { makeGetCommand } from '../../helpers/command-factory.ts'; +import type { PaginatedResponse } from '../../client/types.ts'; + +type Item = Record; export const appsGetCommand = makeGetCommand({ name: 'get', description: 'Get application details', - displayFields: (item: Record) => ({ + displayFields: (item: Item) => ({ ID: item['id'], Name: item['display_name'] ?? item['name'], Status: item['status'], @@ -11,7 +14,18 @@ export const appsGetCommand = makeGetCommand({ 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(`/applications/${id}`), + client.get>(`/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 }; + }, }); diff --git a/src/commands/apps/processes/update.ts b/src/commands/apps/processes/update.ts index 73e6d23..f4c3254 100644 --- a/src/commands/apps/processes/update.ts +++ b/src/commands/apps/processes/update.ts @@ -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'; @@ -14,10 +15,13 @@ export const processesUpdateCommand = new Command('update') .option('--resource-type ', 'Process resource type ID') .option('--instances ', 'Instance count') .addOption(jsonOption()) + .addOption(dryRunOption()) .action(async (processId: string, opts: Record) => { const client = createClient({ apiUrl: opts['apiUrl'] as string | undefined }); const json = resolveJsonMode(opts); try { + validateId(opts['appId'] as string, 'app-id'); + validateId(processId, 'process-id'); const body: Record = {}; if (opts['name']) body['display_name'] = opts['name']; if (opts['command']) body['entrypoint'] = opts['command']; @@ -27,11 +31,13 @@ export const processesUpdateCommand = new Command('update') type: 'manual', config: { instanceCount: Number(opts['instances']) }, }; + const path = `/applications/${opts['appId']}/processes/${processId}`; + if (opts['dryRun']) { + printJson({ dry_run: true, command: 'update', method: 'PATCH', path, body }); + return; + } if (!json) startSpinner('Updating...'); - const result = await client.patch( - `/applications/${opts['appId']}/processes/${processId}`, - body, - ); + const result = await client.patch(path, body); stopSpinner(); if (json) { printJson(result); diff --git a/src/commands/apps/suspend.ts b/src/commands/apps/suspend.ts index 207e282..775a709 100644 --- a/src/commands/apps/suspend.ts +++ b/src/commands/apps/suspend.ts @@ -3,6 +3,7 @@ import { makeActionCommand } from '../../helpers/command-factory.ts'; export const appsSuspendCommand = makeActionCommand({ name: 'suspend', description: 'Suspend an application', + confirm: 'Suspending takes the application offline until it is activated again.', apiCall: (client, id) => client.post(`/applications/${id}/suspend`), successMessage: 'Application suspended.', spinnerText: 'Suspending application...', diff --git a/src/commands/auth/status.ts b/src/commands/auth/status.ts index 9c98b4e..ea23d49 100644 --- a/src/commands/auth/status.ts +++ b/src/commands/auth/status.ts @@ -1,22 +1,44 @@ import { Command } from 'commander'; -import { isLoggedIn, getTokenSource } from '../../auth/token-store.ts'; +import { getTokenSource } from '../../auth/token-store.ts'; +import { createClient } from '../../client/api-client.ts'; +import { SevallaApiError } from '../../errors/api-error.ts'; +import { handleError } from '../../errors/handler.ts'; import { printJson, printSuccess } from '../../output/formatter.ts'; import { jsonOption, resolveJsonMode } from '../../helpers/flags.ts'; import chalk from 'chalk'; export function makeAuthStatusCommand(): Command { const cmd = new Command('status') - .description('Show current authentication status') + .description('Show current authentication status (checks the token with the API)') .addOption(jsonOption()); cmd.action(async (opts: Record) => { const json = resolveJsonMode(opts); - const loggedIn = isLoggedIn(); const source = getTokenSource(); + let rejected = false; + if (source) { + // A stored token can be revoked or expired, so ask the API rather than trusting it exists + try { + await createClient({ apiUrl: opts['apiUrl'] as string | undefined }).get( + '/api-keys/validate', + ); + } catch (error) { + if (!(error instanceof SevallaApiError && error.status === 401)) handleError(error, json); + rejected = true; + } + } + const loggedIn = source !== null && !rejected; + if (json) { - printJson({ logged_in: loggedIn, source }); + printJson({ logged_in: loggedIn, source, ...(rejected && { token_rejected: true }) }); } else if (loggedIn) { printSuccess(`Authenticated (source: ${source})`); + } else if (rejected) { + console.log( + chalk.yellow( + `The token from ${source} was rejected (revoked or expired). Run "sevalla login" or set a valid SEVALLA_API_TOKEN.`, + ), + ); } else { console.log(chalk.yellow('Not logged in. Run "sevalla login" to authenticate.')); } diff --git a/src/commands/databases/backups/restore.ts b/src/commands/databases/backups/restore.ts index 03efb92..6e01dda 100644 --- a/src/commands/databases/backups/restore.ts +++ b/src/commands/databases/backups/restore.ts @@ -3,6 +3,7 @@ import { makeSubResourceActionCommand } from '../../../helpers/command-factory.t export const backupsRestoreCommand = makeSubResourceActionCommand({ name: 'restore', description: 'Restore a database backup', + confirm: "Restoring overwrites the database's current data with the backup.", parentIdName: 'db-id', childIdName: 'backup-id', apiCall: (client, parentId, childId) => diff --git a/src/commands/databases/reset-password.ts b/src/commands/databases/reset-password.ts index e1e6245..80336d7 100644 --- a/src/commands/databases/reset-password.ts +++ b/src/commands/databases/reset-password.ts @@ -3,6 +3,7 @@ import { makeActionCommand } from '../../helpers/command-factory.ts'; export const databasesResetPasswordCommand = makeActionCommand({ name: 'reset-password', description: 'Reset database password', + confirm: 'Resetting the password breaks every connection that uses the current one.', options: [{ flags: '--new-password ', description: 'New password', required: true }], apiCall: (client, id, opts) => client.post(`/databases/${id}/reset-password`, { new_password: opts['newPassword'] }), diff --git a/src/commands/databases/suspend.ts b/src/commands/databases/suspend.ts index b58b957..40963f2 100644 --- a/src/commands/databases/suspend.ts +++ b/src/commands/databases/suspend.ts @@ -3,6 +3,7 @@ import { makeActionCommand } from '../../helpers/command-factory.ts'; export const databasesSuspendCommand = makeActionCommand({ name: 'suspend', description: 'Suspend a database', + confirm: 'Suspending takes the database offline until it is activated again.', apiCall: (client, id) => client.post(`/databases/${id}/suspend`), successMessage: 'Database suspended.', spinnerText: 'Suspending database...', diff --git a/src/commands/global-env-vars/create.ts b/src/commands/global-env-vars/create.ts index 67920ab..a2bd602 100644 --- a/src/commands/global-env-vars/create.ts +++ b/src/commands/global-env-vars/create.ts @@ -1,4 +1,5 @@ import { makeCreateCommand } from '../../helpers/command-factory.ts'; +import { omitValue } from '../../helpers/secrets.ts'; export const globalEnvVarsCreateCommand = makeCreateCommand({ name: 'create', @@ -10,12 +11,13 @@ export const globalEnvVarsCreateCommand = makeCreateCommand({ displayFields: (item: Record) => ({ ID: item['id'], Key: item['key'], - Value: item['value'], }), apiCall: (client, opts) => - client.post('/applications/global-env-vars', { - key: opts['key'], - value: opts['value'], - }), + client + .post>('/applications/global-env-vars', { + key: opts['key'], + value: opts['value'], + }) + .then(omitValue), successMessage: 'Global environment variable created.', }); diff --git a/src/commands/global-env-vars/list.ts b/src/commands/global-env-vars/list.ts index 532556e..2c306af 100644 --- a/src/commands/global-env-vars/list.ts +++ b/src/commands/global-env-vars/list.ts @@ -1,12 +1,9 @@ import { makeListCommand } from '../../helpers/command-factory.ts'; +import { envVarColumns, listEnvVars, showValuesOption } from '../../helpers/secrets.ts'; export const globalEnvVarsListCommand = makeListCommand({ name: 'list', description: 'List all global environment variables', - columns: [ - { header: 'ID', key: 'id' }, - { header: 'Key', key: 'key' }, - { header: 'Value', key: 'value' }, - ], - apiCall: (client) => client.get('/applications/global-env-vars'), -}); + columns: envVarColumns, + apiCall: (client, opts) => listEnvVars(client, '/applications/global-env-vars', opts), +}).addOption(showValuesOption()); diff --git a/src/commands/global-env-vars/update.ts b/src/commands/global-env-vars/update.ts index 7c54ca0..2a9bfcc 100644 --- a/src/commands/global-env-vars/update.ts +++ b/src/commands/global-env-vars/update.ts @@ -1,4 +1,5 @@ import { makeUpdateCommand } from '../../helpers/command-factory.ts'; +import { omitValue } from '../../helpers/secrets.ts'; export const globalEnvVarsUpdateCommand = makeUpdateCommand({ name: 'update', @@ -10,12 +11,13 @@ export const globalEnvVarsUpdateCommand = makeUpdateCommand({ displayFields: (item: Record) => ({ ID: item['id'], Key: item['key'], - Value: item['value'], }), apiCall: (client, id, opts) => { const body: Record = {}; if (opts['key']) body['key'] = opts['key']; if (opts['value']) body['value'] = opts['value']; - return client.put(`/applications/global-env-vars/${id}`, body); + return client + .put>(`/applications/global-env-vars/${id}`, body) + .then(omitValue); }, }); diff --git a/src/commands/object-storage/rotate-credentials.ts b/src/commands/object-storage/rotate-credentials.ts index a8fc3ad..84a7153 100644 --- a/src/commands/object-storage/rotate-credentials.ts +++ b/src/commands/object-storage/rotate-credentials.ts @@ -3,6 +3,7 @@ import { makeActionCommand } from '../../helpers/command-factory.ts'; export const objectStorageRotateCredentialsCommand = makeActionCommand>({ name: 'rotate-credentials', description: 'Rotate access and secret keys for an object storage bucket', + confirm: 'Rotating invalidates the current keys (immediately, or after --old-keys-ttl-hours).', options: [ { flags: '--old-keys-ttl-hours ', diff --git a/src/commands/schema.ts b/src/commands/schema.ts index ffc68a9..573d98e 100644 --- a/src/commands/schema.ts +++ b/src/commands/schema.ts @@ -4,7 +4,9 @@ import { printJson } from '../output/formatter.ts'; interface SchemaOption { long: string; short?: string; - required: boolean; + // Commander's `required` only means the option takes a value; agents read it as "must be passed" + takesValue: boolean; + mandatory: boolean; description: string; defaultValue?: unknown; } @@ -38,7 +40,8 @@ function walkCommand(cmd: Command, parentPath: string): SchemaEntry { .map((opt) => ({ long: opt.long ?? '', short: opt.short, - required: opt.required ?? false, + takesValue: opt.required || opt.optional, + mandatory: opt.mandatory, description: opt.description, defaultValue: opt.defaultValue, })); diff --git a/src/commands/static-sites/env-vars/create.ts b/src/commands/static-sites/env-vars/create.ts index 8640640..6c32a88 100644 --- a/src/commands/static-sites/env-vars/create.ts +++ b/src/commands/static-sites/env-vars/create.ts @@ -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>({ name: 'create', description: 'Create an environment variable', idArg: 'site-id', @@ -9,9 +10,11 @@ export const envVarsCreateCommand = makeActionCommand({ { flags: '--value ', description: 'Variable value', required: true }, ], apiCall: (client, id, opts) => - client.post(`/static-sites/${id}/env-vars`, { - key: opts['key'], - value: opts['value'], - }), + client + .post>(`/static-sites/${id}/env-vars`, { + key: opts['key'], + value: opts['value'], + }) + .then(omitValue), successMessage: 'Environment variable created.', }); diff --git a/src/commands/static-sites/env-vars/list.ts b/src/commands/static-sites/env-vars/list.ts index 237267f..afc1328 100644 --- a/src/commands/static-sites/env-vars/list.ts +++ b/src/commands/static-sites/env-vars/list.ts @@ -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(`/static-sites/${opts['siteId']}/env-vars`), + columns: envVarColumns, + apiCall: (client, opts) => listEnvVars(client, `/static-sites/${opts['siteId']}/env-vars`, opts), parentIdFlag: { name: 'site-id', description: 'Static site ID' }, -}); +}).addOption(showValuesOption()); diff --git a/src/commands/static-sites/env-vars/update.ts b/src/commands/static-sites/env-vars/update.ts index 92aa61c..247d2d9 100644 --- a/src/commands/static-sites/env-vars/update.ts +++ b/src/commands/static-sites/env-vars/update.ts @@ -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'; @@ -12,18 +14,32 @@ export const envVarsUpdateCommand = new Command('update') .option('--key ', 'Variable key') .option('--value ', 'Variable value') .addOption(jsonOption()) + .addOption(dryRunOption()) .action(async (envVarId: string, opts: Record) => { const client = createClient({ apiUrl: opts['apiUrl'] as string | undefined }); const json = resolveJsonMode(opts); try { + validateId(opts['siteId'] as string, 'site-id'); + validateId(envVarId, 'env-var-id'); const body: Record = {}; if (opts['key']) body['key'] = opts['key']; if (opts['value']) body['value'] = opts['value']; + const path = `/static-sites/${opts['siteId']}/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(`/static-sites/${opts['siteId']}/env-vars/${envVarId}`, body); + const result = await client.put>(path, body); stopSpinner(); if (json) { - printJson(result); + printJson(omitValue(result)); } else { printSuccess('Environment variable updated.'); } diff --git a/src/commands/webhooks/roll-secret.ts b/src/commands/webhooks/roll-secret.ts index dbe56ef..fc4a3e6 100644 --- a/src/commands/webhooks/roll-secret.ts +++ b/src/commands/webhooks/roll-secret.ts @@ -3,6 +3,8 @@ import { makeActionCommand } from '../../helpers/command-factory.ts'; export const webhooksRollSecretCommand = makeActionCommand({ name: 'roll-secret', description: 'Roll webhook secret', + confirm: + 'Rolling invalidates the current signing secret (immediately, or after --expire-in-hours).', options: [ { flags: '--expire-in-hours ', diff --git a/src/helpers/command-factory.ts b/src/helpers/command-factory.ts index 0649e6f..627d9e4 100644 --- a/src/helpers/command-factory.ts +++ b/src/helpers/command-factory.ts @@ -1,8 +1,10 @@ -import { Command } from 'commander'; +import { Command, Option } from 'commander'; import type { ApiClient } from '../client/api-client.ts'; import { createClient } from '../client/api-client.ts'; +import { SevallaCliError } from '../errors/cli-error.ts'; import { handleError } from '../errors/handler.ts'; import { + morePagesHint, printJson, printTable, printResource, @@ -22,6 +24,7 @@ import { import type { ColumnDef } from '../output/table.ts'; import type { PaginatedResponse, MetricsQuery, QueryParams } from '../client/types.ts'; import { validateId } from './validate.ts'; +import { maskSecrets } from './secrets.ts'; interface CommandContext { client: ApiClient; @@ -41,6 +44,45 @@ function parseDataOption(raw: string): unknown { } } +type BodyOption = { flags: string; description: string; required?: boolean }; + +// --data replaces the whole body, so it can't be combined with the flags, and with --data +// the flags' requiredness is checked in the action instead of by commander +function addBodyOptions( + cmd: Command, + options: BodyOption[], + apiPath: string | undefined, +): (opts: Record) => void { + const added = options.map((opt) => { + const requiredUnlessData = opt.required && apiPath; + const option = new Option( + opt.flags, + requiredUnlessData ? `${opt.description} (required unless --data is used)` : opt.description, + ); + if (opt.required && !apiPath) option.makeOptionMandatory(); + cmd.addOption(option); + return option; + }); + + if (apiPath) { + cmd.addOption(dataOption().conflicts(added.map((o) => o.attributeName()))); + } + + return (opts) => { + if (opts['data'] !== undefined) return; + const missing = added.find( + (o, i) => options[i]?.required && opts[o.attributeName()] === undefined, + ); + if (missing) cmd.error(`error: required option '${missing.flags}' not specified`); + }; +} + +// Irreversible actions need --confirm; the message says what can't be undone +function requireConfirm(opts: Record, warning: string | undefined): void { + if (warning && !opts['confirm']) + throw new SevallaCliError(`${warning} Use --confirm to proceed.`); +} + export interface ListCommandConfig { name: string; description: string; @@ -100,6 +142,9 @@ export function makeListCommand(config: ListCommandConfig): Command { stopSpinner(); if (json) { printJson(Array.isArray(result) ? result : result.data); + // stderr keeps stdout a plain array while still telling the caller the list is partial + const hint = morePagesHint(result); + if (hint) console.error(hint); } else { printTable(result, config.columns); } @@ -161,24 +206,14 @@ export function makeCreateCommand(config: CreateCommandConfig): Command { const cmd = new Command(config.name).description(config.description); cmd.addOption(jsonOption()); cmd.addOption(dryRunOption()); - - if (config.apiPath) { - cmd.addOption(dataOption()); - } - - for (const opt of config.options) { - if (opt.required) { - cmd.requiredOption(opt.flags, opt.description); - } else { - cmd.option(opt.flags, opt.description); - } - } + const checkRequired = addBodyOptions(cmd, config.options, config.apiPath); cmd.action(async (opts: Record) => { + checkRequired(opts); const { client, json } = getContext(opts); try { // Handle --data with raw JSON body - if (opts['data'] && config.apiPath) { + if (opts['data'] !== undefined && config.apiPath) { const body = parseDataOption(opts['data'] as string); if (opts['dryRun']) { printJson({ @@ -186,7 +221,7 @@ export function makeCreateCommand(config: CreateCommandConfig): Command { command: config.name, method: 'POST', path: config.apiPath, - body, + body: maskSecrets(body), }); return; } @@ -215,7 +250,7 @@ export function makeCreateCommand(config: CreateCommandConfig): Command { delete dryOpts['dryRun']; delete dryOpts['apiUrl']; delete dryOpts['data']; - printJson({ dry_run: true, command: config.name, options: dryOpts }); + printJson({ dry_run: true, command: config.name, options: maskSecrets(dryOpts) }); return; } @@ -259,30 +294,26 @@ export function makeUpdateCommand(config: UpdateCommandConfig): Command { cmd.argument(`<${argName}>`, 'Resource ID'); cmd.addOption(jsonOption()); cmd.addOption(dryRunOption()); - - if (config.apiPath) { - cmd.addOption(dataOption()); - } - - for (const opt of config.options) { - if (opt.required) { - cmd.requiredOption(opt.flags, opt.description); - } else { - cmd.option(opt.flags, opt.description); - } - } + const checkRequired = addBodyOptions(cmd, config.options, config.apiPath); cmd.action(async (id: string, opts: Record) => { + checkRequired(opts); const { client, json } = getContext(opts); try { validateId(id, argName); // Handle --data with raw JSON body - if (opts['data'] && config.apiPath) { + if (opts['data'] !== undefined && config.apiPath) { const body = parseDataOption(opts['data'] as string); const path = `${config.apiPath}/${id}`; if (opts['dryRun']) { - printJson({ dry_run: true, command: config.name, method: 'PATCH', path, body }); + printJson({ + dry_run: true, + command: config.name, + method: 'PATCH', + path, + body: maskSecrets(body), + }); return; } if (!json) startSpinner('Updating...'); @@ -306,7 +337,7 @@ export function makeUpdateCommand(config: UpdateCommandConfig): Command { delete dryOpts['dryRun']; delete dryOpts['apiUrl']; delete dryOpts['data']; - printJson({ dry_run: true, command: config.name, id, options: dryOpts }); + printJson({ dry_run: true, command: config.name, id, options: maskSecrets(dryOpts) }); return; } @@ -386,6 +417,8 @@ export interface ActionCommandConfig { successMessage?: string | ((result: T) => string); spinnerText?: string; displayFields?: (item: T) => Record; + // Set for actions that can't be undone: what is lost, shown when --confirm is missing + confirm?: string; } export function makeActionCommand(config: ActionCommandConfig): Command { @@ -394,6 +427,7 @@ export function makeActionCommand(config: ActionCommandConfig): Com cmd.argument(`<${argName}>`, 'Resource ID'); cmd.addOption(jsonOption()); cmd.addOption(dryRunOption()); + if (config.confirm) cmd.addOption(confirmOption()); if (config.options) { for (const opt of config.options) { @@ -415,6 +449,7 @@ export function makeActionCommand(config: ActionCommandConfig): Com return; } + requireConfirm(opts, config.confirm); if (!json) startSpinner(config.spinnerText ?? 'Processing...'); const result = await config.apiCall(client, id, opts); stopSpinner(); @@ -575,6 +610,8 @@ export interface SubResourceActionConfig { ) => Promise; successMessage?: string; spinnerText?: string; + // Set for actions that can't be undone: what is lost, shown when --confirm is missing + confirm?: string; } export function makeSubResourceActionCommand( @@ -585,6 +622,7 @@ export function makeSubResourceActionCommand( cmd.argument(`<${config.childIdName}>`, `${config.childIdName} ID`); cmd.addOption(jsonOption()); cmd.addOption(dryRunOption()); + if (config.confirm) cmd.addOption(confirmOption()); cmd.action(async (parentId: string, childId: string, opts: Record) => { const { client, json } = getContext(opts); @@ -603,6 +641,7 @@ export function makeSubResourceActionCommand( return; } + requireConfirm(opts, config.confirm); if (!json) startSpinner(config.spinnerText ?? 'Processing...'); const result = await config.apiCall(client, parentId, childId, opts); stopSpinner(); diff --git a/src/helpers/flags.ts b/src/helpers/flags.ts index 49315c1..caaccea 100644 --- a/src/helpers/flags.ts +++ b/src/helpers/flags.ts @@ -5,7 +5,7 @@ export function jsonOption(): Option { } export function confirmOption(): Option { - return new Option('--confirm', 'Skip confirmation prompt').default(false); + return new Option('--confirm', 'Confirm an operation that cannot be undone').default(false); } export function paginationOptions(): Option[] { diff --git a/src/helpers/secrets.ts b/src/helpers/secrets.ts new file mode 100644 index 0000000..c4ddf38 --- /dev/null +++ b/src/helpers/secrets.ts @@ -0,0 +1,43 @@ +import { Option } from 'commander'; +import type { ApiClient } from '../client/api-client.ts'; +import type { PaginatedResponse } from '../client/types.ts'; +import type { ColumnDef } from '../output/table.ts'; + +type Item = Record; + +// Keys whose values dry-run output must not echo back (`value` is an env var value) +const SECRET_KEY = /password|secret|^value$/i; + +export function maskSecrets(body: unknown): unknown { + if (body === null || typeof body !== 'object' || Array.isArray(body)) return body; + return Object.fromEntries( + Object.entries(body).map(([k, v]) => [ + k, + SECRET_KEY.test(k) && v !== undefined ? '********' : v, + ]), + ); +} + +// The API returns env var values decrypted, so they stay out of output unless asked for +export function omitValue({ value: _value, ...rest }: Item): Item { + return rest; +} + +export function showValuesOption(): Option { + return new Option('--show-values', 'Include variable values (returned decrypted)').default(false); +} + +export const envVarColumns: ColumnDef[] = [ + { header: 'ID', key: 'id' }, + { header: 'Key', key: 'key' }, + { header: 'Value', get: (item) => ('value' in item ? String(item['value']) : '(hidden)') }, +]; + +export async function listEnvVars( + client: ApiClient, + path: string, + opts: Record, +): Promise> { + const res = await client.get>(path); + return opts['showValues'] ? res : { ...res, data: res.data.map(omitValue) }; +} diff --git a/src/output/formatter.ts b/src/output/formatter.ts index c16cd6b..6ba0314 100644 --- a/src/output/formatter.ts +++ b/src/output/formatter.ts @@ -22,10 +22,25 @@ export function printTable(response: PaginatedResponse | T[], columns: Col const limit = response.limit ?? items.length; const start = offset + 1; const end = Math.min(offset + limit, response.total); - console.log(chalk.dim(`Showing ${start}-${end} of ${response.total}`)); + console.log( + chalk.dim(morePagesHint(response) ?? `Showing ${start}-${end} of ${response.total}`), + ); } } +// So a caller seeing one page doesn't conclude that something missing from it doesn't exist +export function morePagesHint(response: PaginatedResponse | T[]): string | null { + if (Array.isArray(response) || response.total === undefined || response.data.length === 0) { + return null; + } + const offset = response.offset ?? 0; + const limit = response.limit || response.data.length; + const end = Math.min(offset + limit, response.total); + if (end >= response.total) return null; + const nextPage = Math.floor(offset / limit) + 2; + return `Showing ${offset + 1}-${end} of ${response.total}. Use --page ${nextPage} for the next page.`; +} + export function printResource(data: Record): void { console.log(renderKeyValue(data)); } diff --git a/tests/e2e/tests/api-keys.test.ts b/tests/e2e/tests/api-keys.test.ts index 97457d7..197185e 100644 --- a/tests/e2e/tests/api-keys.test.ts +++ b/tests/e2e/tests/api-keys.test.ts @@ -121,7 +121,7 @@ describe('api-keys', () => { // --------------------------------------------------------------------------- it('should rotate an API key', async () => { assert.ok(createdKeyId, 'createdKeyId must be set') - const res = await sevalla(['api-keys', 'rotate', createdKeyId]) + const res = await sevalla(['api-keys', 'rotate', createdKeyId, '--confirm']) assert.equal(res.exitCode, 0, `api-keys rotate failed: ${res.stderr}`) }) diff --git a/tests/e2e/tests/application-deployments.test.ts b/tests/e2e/tests/application-deployments.test.ts index 8e75a60..ab5adc7 100644 --- a/tests/e2e/tests/application-deployments.test.ts +++ b/tests/e2e/tests/application-deployments.test.ts @@ -117,6 +117,16 @@ describe('application deployments and processes', () => { assert.equal(deployment.id, deploymentId, 'returned deployment id should match') }) + it('should dry-run a deployment cancel without calling the API', async () => { + assert.ok(createdAppId, 'createdAppId must be set from before hook') + + const res = await sevalla(['apps', 'deployments', 'cancel', 'some-deployment', '--app-id', createdAppId, '--dry-run']) + assert.equal(res.exitCode, 0, `cancel --dry-run failed: ${res.stderr}`) + const body = res.json<{ dry_run: boolean; path: string }>() + assert.equal(body.dry_run, true) + assert.equal(body.path, `/applications/${createdAppId}/deployments/some-deployment/cancel`) + }) + it('should cancel a deployment', async () => { assert.ok(createdAppId, 'createdAppId must be set from before hook') @@ -136,6 +146,16 @@ describe('application deployments and processes', () => { assert.equal(res.exitCode, 0, `apps deployments cancel failed: ${res.stderr}`) }) + it('should send skip-build-cache and restart triggers the API accepts', async () => { + assert.ok(createdAppId, 'createdAppId must be set from before hook') + + // A restart needs an earlier successful build, so the API may refuse on state, but never on request shape + for (const flag of ['--skip-build-cache', '--restart']) { + const res = await sevalla(['apps', 'deployments', 'trigger', createdAppId, flag], { timeout: 120_000 }) + assert.ok(refusedOnStateOnly(res), `trigger ${flag} failed on request shape, auth or crash: ${res.stderr}`) + } + }) + it('should send a rollback request the API accepts', async () => { assert.ok(createdAppId, 'createdAppId must be set from before hook') @@ -236,6 +256,20 @@ describe('application deployments and processes', () => { workerProcessId = proc.id }) + it('should dry-run a process update without calling the API', async () => { + assert.ok(createdAppId, 'createdAppId must be set from before hook') + assert.ok(workerProcessId, 'workerProcessId must be set from create test') + + const res = await sevalla([ + 'apps', 'processes', 'update', workerProcessId, '--app-id', createdAppId, '--instances', '3', '--dry-run', + ]) + assert.equal(res.exitCode, 0, `apps processes update --dry-run failed: ${res.stderr}`) + const body = res.json<{ dry_run: boolean; method: string; body: Record }>() + assert.equal(body.dry_run, true) + assert.equal(body.method, 'PATCH') + assert.deepEqual(body.body, { scaling_strategy: { type: 'manual', config: { instanceCount: 3 } } }) + }) + it('should update a process', async () => { assert.ok(createdAppId, 'createdAppId must be set from before hook') assert.ok(workerProcessId, 'workerProcessId must be set from create test') diff --git a/tests/e2e/tests/application-env-vars.test.ts b/tests/e2e/tests/application-env-vars.test.ts index 2a78b55..4791942 100644 --- a/tests/e2e/tests/application-env-vars.test.ts +++ b/tests/e2e/tests/application-env-vars.test.ts @@ -114,6 +114,8 @@ describe('application environment variables', () => { const envVar = res.json() assert.ok(envVar.id, 'created env var should have an id') + assert.ok(!('value' in envVar), 'create should not echo the value') + assert.ok(!res.stdout.includes('test-value-123')) createdEnvVarId = envVar.id }) @@ -129,6 +131,44 @@ describe('application environment variables', () => { const found = envVars.find((v) => v.id === createdEnvVarId) assert.ok(found, 'created env var should appear in the list') + assert.ok(!('value' in found), 'values should be hidden by default') + }) + + it('should list environment variable values with --show-values', async () => { + assert.ok(createdAppId, 'createdAppId must be set from before hook') + + const res = await sevalla(['apps', 'env-vars', 'list', '--app-id', createdAppId, '--show-values']) + assert.equal(res.exitCode, 0, `env-vars list failed: ${res.stderr}`) + const found = res.json().find((v) => v.id === createdEnvVarId) + assert.equal(found?.value, 'test-value-123') + }) + + it('should say on stderr when more pages exist', async () => { + assert.ok(createdAppId, 'createdAppId must be set from before hook') + + const second = await sevalla([ + 'apps', 'env-vars', 'create', createdAppId, '--key', 'E2E_TEST_VAR_2', '--value', 'x', + ]) + assert.equal(second.exitCode, 0, `env-vars create failed: ${second.stderr}`) + + const res = await sevalla(['apps', 'env-vars', 'list', '--app-id', createdAppId, '--per-page', '1']) + assert.equal(res.exitCode, 0, `env-vars list failed: ${res.stderr}`) + assert.equal(res.json().length, 1) + assert.match(res.stderr, /Showing 1-1 of \d+\. Use --page 2 for the next page\./) + }) + + it('should mask the value in an update dry run', async () => { + assert.ok(createdAppId, 'createdAppId must be set from before hook') + assert.ok(createdEnvVarId, 'createdEnvVarId must be set from create test') + + const res = await sevalla([ + 'apps', 'env-vars', 'update', createdEnvVarId, '--app-id', createdAppId, + '--value', 'dry-run-secret', '--dry-run', + ]) + assert.equal(res.exitCode, 0, `env-vars update --dry-run failed: ${res.stderr}`) + const body = res.json<{ dry_run: boolean; body: { value: string } }>() + assert.equal(body.dry_run, true) + assert.equal(body.body.value, '********') }) it('should update an environment variable', async () => { @@ -146,6 +186,7 @@ describe('application environment variables', () => { 'updated-value-456', ]) assert.equal(res.exitCode, 0, `env-vars update failed: ${res.stderr}`) + assert.ok(!res.stdout.includes('updated-value-456'), 'update should not echo the value') }) it('should delete an environment variable', async () => { diff --git a/tests/e2e/tests/application-lifecycle.test.ts b/tests/e2e/tests/application-lifecycle.test.ts index eed2b80..eaca4f2 100644 --- a/tests/e2e/tests/application-lifecycle.test.ts +++ b/tests/e2e/tests/application-lifecycle.test.ts @@ -97,10 +97,18 @@ describe('application lifecycle', () => { } }) - it('should suspend an application', async () => { + it('should refuse to suspend an application without --confirm', async () => { assert.ok(appId, 'appId must be set from before hook') const res = await sevalla(['apps', 'suspend', appId]) + assert.equal(res.exitCode, 1) + assert.match(res.stderr, /Use --confirm to proceed/) + }) + + it('should suspend an application', async () => { + assert.ok(appId, 'appId must be set from before hook') + + const res = await sevalla(['apps', 'suspend', appId, '--confirm']) assert.equal(res.exitCode, 0, `apps suspend failed: ${res.stderr}`) }) diff --git a/tests/e2e/tests/application-settings.test.ts b/tests/e2e/tests/application-settings.test.ts index f508872..d7dbf5a 100644 --- a/tests/e2e/tests/application-settings.test.ts +++ b/tests/e2e/tests/application-settings.test.ts @@ -121,14 +121,14 @@ describe('application settings', () => { it('should regenerate deployment hook', async () => { assert.ok(appId, 'appId must be set from before hook') - const res = await sevalla(['apps', 'deployment-hook', 'regenerate', appId]) + const res = await sevalla(['apps', 'deployment-hook', 'regenerate', appId, '--confirm']) assert.equal(res.exitCode, 0, `deployment-hook regenerate failed: ${res.stderr}`) }) it('should disable deployment hook', async () => { assert.ok(appId, 'appId must be set from before hook') - const res = await sevalla(['apps', 'deployment-hook', 'disable', appId]) + const res = await sevalla(['apps', 'deployment-hook', 'disable', appId, '--confirm']) assert.equal(res.exitCode, 0, `deployment-hook disable failed: ${res.stderr}`) }) diff --git a/tests/e2e/tests/applications.test.ts b/tests/e2e/tests/applications.test.ts index f343c4d..39f0c8c 100644 --- a/tests/e2e/tests/applications.test.ts +++ b/tests/e2e/tests/applications.test.ts @@ -116,6 +116,7 @@ describe('applications', () => { const app = res.json() assert.equal(app.id, createdAppId, 'returned app id should match') + assert.ok('primary_domain' in app, 'apps get should include primary_domain') }) it('should fail to get a non-existent application', async () => { diff --git a/tests/e2e/tests/common.test.ts b/tests/e2e/tests/common.test.ts index 556231a..93e7ff5 100644 --- a/tests/e2e/tests/common.test.ts +++ b/tests/e2e/tests/common.test.ts @@ -49,6 +49,47 @@ describe('common / cross-cutting concerns', () => { assert.ok(body.options.length > 0, 'expected at least one option') }) + it('sevalla schema marks mandatory options separately from options that take a value', async () => { + const res = await sevalla(['schema', 'databases.create']) + assert.equal(res.exitCode, 0) + const options = res.json<{ options: Array> }>().options + const name = options.find((o) => o.long === '--name') + const project = options.find((o) => o.long === '--project') + const json = options.find((o) => o.long === '--json') + assert.deepEqual([name?.mandatory, name?.takesValue], [true, true]) + assert.deepEqual([project?.mandatory, project?.takesValue], [false, true]) + assert.deepEqual([json?.mandatory, json?.takesValue], [false, false]) + assert.ok(!('required' in (name ?? {})), 'the misleading `required` field is gone') + }) + + it('auth status should report a rejected token as not logged in', async () => { + const res = await sevalla(['auth', 'status'], { env: { SEVALLA_API_TOKEN: 'revoked-e2e-token' } }) + assert.equal(res.exitCode, 0, `auth status failed: ${res.stderr}`) + assert.deepEqual(res.json(), { logged_in: false, source: 'environment', token_rejected: true }) + }) + + it('--data cannot be combined with body flags', async () => { + const res = await sevalla(['apps', 'create', '--data', '{}', '--name', 'x']) + assert.equal(res.exitCode, 1) + assert.match(res.stderr, /cannot be used with option '--name '/) + }) + + it('--data works without the flags it replaces', async () => { + const res = await sevalla(['apps', 'create', '--data', '{"display_name":"x","db_password":"p"}', '--dry-run']) + assert.equal(res.exitCode, 0, `apps create --data --dry-run failed: ${res.stderr}`) + assert.deepEqual(res.json<{ body: unknown }>().body, { display_name: 'x', db_password: '********' }) + }) + + it('databases create --dry-run masks the password', async () => { + const res = await sevalla([ + 'databases', 'create', '--name', 'x', '--type', 'postgresql', '--db-version', '16', + '--cluster', 'c', '--resource-type', 'r', '--db-name', 'd', '--db-password', 'hunter2-e2e', '--dry-run', + ]) + assert.equal(res.exitCode, 0, `databases create --dry-run failed: ${res.stderr}`) + assert.ok(!res.stdout.includes('hunter2-e2e'), 'password must not be echoed') + assert.equal(res.json<{ options: { dbPassword: string } }>().options.dbPassword, '********') + }) + // --------------------------------------------------------------------------- // Schema – non-existent command should fail // --------------------------------------------------------------------------- diff --git a/tests/e2e/tests/databases.test.ts b/tests/e2e/tests/databases.test.ts index a215284..9f03541 100644 --- a/tests/e2e/tests/databases.test.ts +++ b/tests/e2e/tests/databases.test.ts @@ -252,7 +252,7 @@ describe(RESOURCE, () => { if (!backup) return t.skip('no backup available on this cluster') await waitForDbReady(createdId) - const res = await sevalla([RESOURCE, 'backups', 'restore', createdId, backup.id]) + const res = await sevalla([RESOURCE, 'backups', 'restore', createdId, backup.id, '--confirm']) assert.equal(res.exitCode, 0, `backups restore failed: ${res.stderr}`) await waitForDbReady(createdId) }) @@ -262,7 +262,7 @@ describe(RESOURCE, () => { // --------------------------------------------------------------------------- it('should reset the database password', async () => { assert.ok(createdId, 'No database was created') - const res = await sevalla([RESOURCE, 'reset-password', createdId, '--new-password', 'E2eNewPass5678']) + const res = await sevalla([RESOURCE, 'reset-password', createdId, '--new-password', 'E2eNewPass5678', '--confirm']) assert.equal(res.exitCode, 0, `reset-password failed: ${res.stderr}`) await waitForDbReady(createdId) }) @@ -273,7 +273,7 @@ describe(RESOURCE, () => { it('should suspend the database', async () => { assert.ok(createdId, 'No database was created') await waitForDbReady(createdId) - const res = await sevalla([RESOURCE, 'suspend', createdId]) + const res = await sevalla([RESOURCE, 'suspend', createdId, '--confirm']) assert.equal(res.exitCode, 0, `suspend failed: ${res.stderr}`) // Wait for suspended state for (let i = 0; i < 60; i++) { diff --git a/tests/e2e/tests/object-storage.test.ts b/tests/e2e/tests/object-storage.test.ts index 6fa062b..23b7d01 100644 --- a/tests/e2e/tests/object-storage.test.ts +++ b/tests/e2e/tests/object-storage.test.ts @@ -89,6 +89,20 @@ describe(RESOURCE, () => { } }) + it('should refuse to rotate credentials without --confirm', async () => { + assert.ok(bucketId, 'No bucket was created') + const res = await sevalla([RESOURCE, 'rotate-credentials', bucketId]) + assert.equal(res.exitCode, 1) + assert.match(res.stderr, /Use --confirm to proceed/) + }) + + it('should rotate credentials with --confirm', async () => { + assert.ok(bucketId, 'No bucket was created') + const res = await sevalla([RESOURCE, 'rotate-credentials', bucketId, '--confirm']) + assert.equal(res.exitCode, 0, `rotate-credentials failed: ${res.stderr}`) + assert.ok(res.json<{ access_key?: string }>().access_key, 'new access key should be returned') + }) + it('should refuse to delete objects without --confirm', async () => { assert.ok(bucketId, 'No bucket was created') const res = await sevalla([RESOURCE, 'objects', 'delete', bucketId, '--keys', 'e2e-a.txt']) diff --git a/tests/e2e/tests/static-sites.test.ts b/tests/e2e/tests/static-sites.test.ts index 7637f82..44fac6b 100644 --- a/tests/e2e/tests/static-sites.test.ts +++ b/tests/e2e/tests/static-sites.test.ts @@ -194,6 +194,11 @@ describe(RESOURCE, () => { const envVars = res.json<{ id: string }[]>() assert.ok(Array.isArray(envVars)) assert.ok(envVars.length > 0, 'should have at least one env var') + assert.ok(envVars.every((v) => !('value' in v)), 'values should be hidden by default') + + const shown = await sevalla([RESOURCE, 'env-vars', 'list', '--site-id', createdId, '--show-values']) + assert.equal(shown.exitCode, 0, `env-vars list --show-values failed: ${shown.stderr}`) + assert.ok(shown.json<{ value?: string }[]>().every((v) => typeof v.value === 'string')) }) it('should update a static site env var', async () => { @@ -205,6 +210,7 @@ describe(RESOURCE, () => { '--value', 'updated-value-456', ]) assert.equal(res.exitCode, 0, `env-vars update failed: ${res.stderr}`) + assert.ok(!res.stdout.includes('updated-value-456'), 'update should not echo the value') }) it('should delete a static site env var', async () => { diff --git a/tests/e2e/tests/webhooks.test.ts b/tests/e2e/tests/webhooks.test.ts index fb531eb..3f7ce08 100644 --- a/tests/e2e/tests/webhooks.test.ts +++ b/tests/e2e/tests/webhooks.test.ts @@ -132,7 +132,7 @@ describe('webhooks', () => { // --------------------------------------------------------------------------- it('should roll webhook secret', async () => { assert.ok(createdWebhookId, 'createdWebhookId must be set') - const res = await sevalla(['webhooks', 'roll-secret', createdWebhookId]) + const res = await sevalla(['webhooks', 'roll-secret', createdWebhookId, '--confirm']) assert.equal(res.exitCode, 0, `webhooks roll-secret failed: ${res.stderr}`) }) diff --git a/tests/unit/helpers/safety.test.ts b/tests/unit/helpers/safety.test.ts new file mode 100644 index 0000000..387ef0d --- /dev/null +++ b/tests/unit/helpers/safety.test.ts @@ -0,0 +1,244 @@ +import { describe, it, mock, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { Command } from 'commander'; +import { + makeActionCommand, + makeCreateCommand, + makeListCommand, +} from '../../../src/helpers/command-factory.ts'; +import { envVarColumns, listEnvVars, maskSecrets } from '../../../src/helpers/secrets.ts'; +import { morePagesHint } from '../../../src/output/formatter.ts'; +import { makeSchemaCommand } from '../../../src/commands/schema.ts'; +import { makeAuthStatusCommand } from '../../../src/commands/auth/status.ts'; + +function capture(): { out: string[]; err: string[] } { + const out: string[] = []; + const err: string[] = []; + mock.method(console, 'log', (...a: unknown[]) => out.push(a.map(String).join(' '))); + mock.method(console, 'error', (...a: unknown[]) => err.push(a.map(String).join(' '))); + return { out, err }; +} + +function mockFetch(body: unknown, status = 200): string[] { + const calls: string[] = []; + mock.method(globalThis, 'fetch', async (url: string) => { + calls.push(url); + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); + }); + return calls; +} + +function throwOnExit(): void { + mock.method(process, 'exit', (code: number) => { + throw new Error(`exit ${code}`); + }); +} + +function quiet(cmd: Command): Command { + cmd.exitOverride(); + cmd.configureOutput({ writeErr: () => {}, writeOut: () => {} }); + return cmd; +} + +describe('Safety and agent-facing behaviour', () => { + afterEach(() => mock.restoreAll()); + + describe('--confirm on irreversible actions', () => { + const make = () => + quiet( + makeActionCommand({ + name: 'suspend', + description: 'Suspend', + confirm: 'Suspending takes it offline.', + apiCall: (client, id) => client.post(`/things/${id}/suspend`), + }), + ); + + it('refuses to call the API without --confirm, as a JSON error in --json mode', async () => { + const calls = mockFetch({}); + const { err } = capture(); + throwOnExit(); + await assert.rejects(make().parseAsync(['abc', '--json'], { from: 'user' }), /exit 1/); + assert.equal(calls.length, 0); + assert.deepEqual(JSON.parse(err.join('\n')), { + error: { message: 'Suspending takes it offline. Use --confirm to proceed.' }, + }); + }); + + it('runs with --confirm, and --dry-run needs no --confirm', async () => { + const calls = mockFetch({}); + capture(); + await make().parseAsync(['abc', '--json', '--confirm'], { from: 'user' }); + assert.equal(calls.length, 1); + await make().parseAsync(['abc', '--json', '--dry-run'], { from: 'user' }); + assert.equal(calls.length, 1); + }); + }); + + describe('--data', () => { + const make = () => + quiet( + makeCreateCommand({ + name: 'create', + description: 'Create', + options: [ + { flags: '--name ', description: 'Name', required: true }, + { flags: '--db-password ', description: 'Password', required: true }, + ], + apiCall: (client, opts) => client.post('/things', { name: opts['name'] }), + apiPath: '/things', + }), + ); + + it('works without the flags that are otherwise required', async () => { + const { out } = capture(); + await make().parseAsync(['--data', '{"name":"x","db_password":"hunter2"}', '--dry-run'], { + from: 'user', + }); + const printed = JSON.parse(out.join('\n')); + assert.deepEqual(printed.body, { name: 'x', db_password: '********' }); + }); + + it('cannot be combined with body flags', async () => { + await assert.rejects( + make().parseAsync(['--data', '{}', '--name', 'x'], { from: 'user' }), + /cannot be used with option '--name '/, + ); + }); + + it('still requires the flags when --data is absent', async () => { + await assert.rejects( + make().parseAsync(['--name', 'x', '--dry-run'], { from: 'user' }), + /required option '--db-password ' not specified/, + ); + }); + + it('masks secrets in flag-based dry-run output', async () => { + const { out } = capture(); + await make().parseAsync(['--name', 'x', '--db-password', 'hunter2', '--dry-run'], { + from: 'user', + }); + assert.ok(!out.join('\n').includes('hunter2')); + assert.equal(JSON.parse(out.join('\n')).options.dbPassword, '********'); + }); + }); + + it('maskSecrets leaves non-secret keys and non-objects alone', () => { + assert.deepEqual(maskSecrets({ key: 'K', value: 'v', name: 'n' }), { + key: 'K', + value: '********', + name: 'n', + }); + assert.deepEqual(maskSecrets([1]), [1]); + }); + + describe('list pagination hint', () => { + it('is given only when more pages exist', () => { + const page = (offset: number) => ({ data: [{}], total: 60, offset, limit: 25 }); + assert.equal(morePagesHint(page(0)), 'Showing 1-25 of 60. Use --page 2 for the next page.'); + assert.equal(morePagesHint(page(25)), 'Showing 26-50 of 60. Use --page 3 for the next page.'); + assert.equal(morePagesHint(page(50)), null); + assert.equal(morePagesHint([{}]), null); + }); + + it('goes to stderr in JSON mode, keeping stdout a plain array', async () => { + mockFetch({ data: [{ id: '1' }], total: 30, offset: 0, limit: 1 }); + const { out, err } = capture(); + const cmd = quiet( + makeListCommand({ + name: 'list', + description: 'List', + columns: [{ header: 'ID', key: 'id' }], + apiCall: (client) => client.get('/things'), + }), + ); + await cmd.parseAsync(['--json', '--per-page', '1'], { from: 'user' }); + assert.deepEqual(JSON.parse(out.join('\n')), [{ id: '1' }]); + assert.match(err.join('\n'), /Use --page 2/); + }); + }); + + describe('env var values', () => { + const res = { data: [{ id: '1', key: 'K', value: 'secret' }], total: 1 }; + + it('are hidden unless --show-values is given', async () => { + mockFetch(res); + const { createClient } = await import('../../../src/client/api-client.ts'); + const client = createClient({ apiUrl: 'https://example.test' }); + const hidden = await listEnvVars(client, '/env', {}); + assert.deepEqual(hidden.data, [{ id: '1', key: 'K' }]); + assert.equal(envVarColumns[2]?.get?.(hidden.data[0] ?? {}), '(hidden)'); + const shown = await listEnvVars(client, '/env', { showValues: true }); + assert.equal(shown.data[0]?.['value'], 'secret'); + }); + }); + + describe('auth status', () => { + const run = async () => { + process.env['SEVALLA_API_TOKEN'] = 'test-token'; + try { + // fetch is mocked, so no request leaves the process + await quiet(makeAuthStatusCommand()).parseAsync(['--json'], { from: 'user' }); + } finally { + delete process.env['SEVALLA_API_TOKEN']; + } + }; + + it('reports a token the API rejects (401) as not logged in, with exit 0', async () => { + mockFetch({ message: 'Unauthorized', status: 401 }, 401); + const { out } = capture(); + throwOnExit(); + await run(); + assert.deepEqual(JSON.parse(out.join('\n')), { + logged_in: false, + source: 'environment', + token_rejected: true, + }); + }); + + it('reports a valid token as logged in', async () => { + mockFetch({ name: 'key' }); + const { out } = capture(); + await run(); + assert.deepEqual(JSON.parse(out.join('\n')), { logged_in: true, source: 'environment' }); + }); + + it('fails with exit 1 on any other API error instead of claiming a rejected token', async () => { + mockFetch({ message: 'Internal error', status: 500 }, 500); + const { out } = capture(); + throwOnExit(); + await assert.rejects(run(), /exit 1/); + assert.equal(out.length, 0); + }); + }); + + it('schema says which options are mandatory and which take a value', async () => { + const program = new Command('sevalla'); + program.addCommand( + new Command('create') + .requiredOption('--name ', 'Name') + .option('--branch ', 'Branch') + .option('--restart', 'Restart'), + ); + program.addCommand(makeSchemaCommand(program)); + const { out } = capture(); + await program.parseAsync(['schema', 'create'], { from: 'user' }); + const opts = JSON.parse(out.join('\n')).options as Array>; + const by = (long: string) => opts.find((o) => o['long'] === long); + assert.deepEqual( + [by('--name'), by('--branch'), by('--restart')].map((o) => [ + o?.['mandatory'], + o?.['takesValue'], + ]), + [ + [true, true], + [false, true], + [false, false], + ], + ); + assert.ok(!('required' in (by('--name') ?? {}))); + }); +});