From d6cb77389e36b5093ec302fe50334076564668d4 Mon Sep 17 00:00:00 2001 From: Peter Kota Date: Sun, 18 Oct 2026 10:29:56 +0200 Subject: [PATCH 1/2] fix: align CLI requests with the v3 OpenAPI spec Validate every command's request against https://api.sevalla.com/v3/openapi.json and fix the mismatches: wrong paths (process metrics), wrong or missing body fields (processes, domains, TCP proxies, private ports, IP restrictions, internal connections, load balancer destinations, CORS policies, object delete, backups, password reset, pipeline promote and preview), required fields that were optional flags, and pagination params sent to endpoints that don't accept them. Also handle empty 202 responses in the API client, fix the duplicated /v3 prefix in the unused endpoint helpers, and make the e2e suite runnable (CLI path, workflow paths, deployment statuses) with coverage for every changed command. Fixes #8 --- .github/workflows/e2e.yml | 32 ++++-- README.md | 38 +++--- src/client/api-client.ts | 4 +- src/client/endpoints/applications.ts | 2 +- src/commands/apps/clone.ts | 9 +- src/commands/apps/deployments/rollback.ts | 10 +- src/commands/apps/deployments/trigger.ts | 6 +- src/commands/apps/domains/add.ts | 3 +- src/commands/apps/domains/update.ts | 5 +- src/commands/apps/ip-restriction/update.ts | 15 +-- src/commands/apps/private-ports/create.ts | 12 +- src/commands/apps/processes/create.ts | 33 ++++-- .../apps/processes/metrics/cpu-limit.ts | 6 +- .../apps/processes/metrics/cpu-usage.ts | 6 +- .../apps/processes/metrics/instance-count.ts | 9 +- .../apps/processes/metrics/memory-limit.ts | 6 +- .../apps/processes/metrics/memory-usage.ts | 6 +- src/commands/apps/processes/update.ts | 14 ++- src/commands/apps/tcp-proxies/create.ts | 10 +- src/commands/databases/backups/create.ts | 4 +- src/commands/databases/backups/list.ts | 1 + src/commands/databases/backups/restore.ts | 2 +- .../databases/connections/internal-create.ts | 8 +- .../databases/connections/internal-list.ts | 1 + .../databases/ip-restriction/update.ts | 15 +-- src/commands/databases/metrics/all-storage.ts | 10 +- .../databases/metrics/used-storage.ts | 10 +- src/commands/databases/reset-password.ts | 4 +- src/commands/git/providers.ts | 1 + .../load-balancers/destinations/create.ts | 21 +++- src/commands/load-balancers/domains/add.ts | 2 +- src/commands/load-balancers/domains/update.ts | 5 +- .../object-storage/cors-policies/create.ts | 21 +++- .../object-storage/cors-policies/list.ts | 1 + .../object-storage/cors-policies/update.ts | 18 +-- src/commands/object-storage/objects/delete.ts | 10 +- src/commands/object-storage/objects/list.ts | 1 + src/commands/pipelines/preview/enable.ts | 4 +- src/commands/pipelines/preview/update.ts | 37 ++++-- src/commands/pipelines/promote.ts | 18 ++- src/commands/pipelines/stages/create.ts | 5 +- src/commands/projects/services/add.ts | 4 +- src/commands/projects/services/remove.ts | 2 +- src/commands/projects/update.ts | 8 +- src/commands/resources/clusters.ts | 1 + src/commands/resources/db-types.ts | 1 + src/commands/resources/process-types.ts | 1 + src/commands/resources/rbac-permissions.ts | 1 + src/commands/resources/rbac-roles.ts | 1 + .../static-sites/deployments/trigger.ts | 6 +- src/commands/static-sites/domains/add.ts | 3 +- src/commands/static-sites/domains/update.ts | 5 +- src/commands/users/list.ts | 1 + src/helpers/command-factory.ts | 14 ++- tests/e2e/helpers/cleanup.ts | 4 +- tests/e2e/helpers/cli.ts | 7 +- tests/e2e/teardown.ts | 2 +- .../e2e/tests/application-deployments.test.ts | 108 +++++++++++++++++- tests/e2e/tests/application-settings.test.ts | 29 +++++ tests/e2e/tests/databases.test.ts | 94 ++++++++++++++- tests/e2e/tests/load-balancers.test.ts | 45 +++++++- tests/e2e/tests/object-storage.test.ts | 19 +++ tests/e2e/tests/pipelines.test.ts | 43 ++++++- tests/e2e/tests/resources.test.ts | 16 +++ tests/e2e/tests/static-sites.test.ts | 31 ++++- tests/unit/client/api-client.test.ts | 10 ++ tests/unit/helpers/command-factory.test.ts | 36 ++++++ 67 files changed, 736 insertions(+), 181 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index d3b21d4..c67c852 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -27,29 +27,37 @@ jobs: matrix: suite: - name: applications - file: e2e/tests/applications.test.ts + file: tests/e2e/tests/applications.test.ts + - name: application-deployments + file: tests/e2e/tests/application-deployments.test.ts + - name: application-env-vars + file: tests/e2e/tests/application-env-vars.test.ts + - name: application-lifecycle + file: tests/e2e/tests/application-lifecycle.test.ts + - name: application-settings + file: tests/e2e/tests/application-settings.test.ts - name: databases - file: e2e/tests/databases.test.ts + file: tests/e2e/tests/databases.test.ts - name: static-sites - file: e2e/tests/static-sites.test.ts + file: tests/e2e/tests/static-sites.test.ts - name: load-balancers - file: e2e/tests/load-balancers.test.ts + file: tests/e2e/tests/load-balancers.test.ts - name: object-storage - file: e2e/tests/object-storage.test.ts + file: tests/e2e/tests/object-storage.test.ts - name: pipelines - file: e2e/tests/pipelines.test.ts + file: tests/e2e/tests/pipelines.test.ts - name: projects - file: e2e/tests/projects.test.ts + file: tests/e2e/tests/projects.test.ts - name: webhooks - file: e2e/tests/webhooks.test.ts + file: tests/e2e/tests/webhooks.test.ts - name: api-keys - file: e2e/tests/api-keys.test.ts + file: tests/e2e/tests/api-keys.test.ts - name: docker-registries - file: e2e/tests/docker-registries.test.ts + file: tests/e2e/tests/docker-registries.test.ts - name: common - file: e2e/tests/common.test.ts + file: tests/e2e/tests/common.test.ts - name: resources - file: e2e/tests/resources.test.ts + file: tests/e2e/tests/resources.test.ts name: e2e (${{ matrix.suite.name }}) steps: - uses: actions/checkout@v4 diff --git a/README.md b/README.md index ba61caa..66f1a33 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,7 @@ 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 rollback --deployment-id +sevalla apps deployments rollback sevalla apps deployments cancel --app-id sevalla apps deployments logs --app-id ``` @@ -108,7 +108,7 @@ sevalla apps deployments logs --app-id ```bash sevalla apps processes list --app-id sevalla apps processes get --app-id -sevalla apps processes create --name web --type web +sevalla apps processes create --name web --type web --resource-type sevalla apps processes update --app-id --instances 3 sevalla apps processes delete --app-id sevalla apps processes trigger --app-id @@ -130,7 +130,7 @@ sevalla apps processes metrics instance-count sevalla apps domains list --app-id sevalla apps domains add --name example.com sevalla apps domains get --app-id -sevalla apps domains update --app-id +sevalla apps domains update --app-id --name my-app sevalla apps domains delete sevalla apps domains set-primary sevalla apps domains toggle @@ -170,14 +170,14 @@ sevalla apps metrics top-pages ```bash sevalla apps ip-restriction get -sevalla apps ip-restriction update --type allow --ip-list "1.2.3.4,5.6.7.8" +sevalla apps ip-restriction update --type allow --ip-list "1.2.3.4,5.6.7.8" --enabled true ``` ### TCP Proxies ```bash sevalla apps tcp-proxies list --app-id -sevalla apps tcp-proxies create --port 3000 +sevalla apps tcp-proxies create --process-id --port 3000 sevalla apps tcp-proxies delete ``` @@ -185,7 +185,7 @@ sevalla apps tcp-proxies delete ```bash sevalla apps private-ports list --app-id -sevalla apps private-ports create --port 8080 --protocol TCP +sevalla apps private-ports create --process-id --port 8080 sevalla apps private-ports delete ``` @@ -209,14 +209,14 @@ sevalla databases update --display-name "My DB" sevalla databases delete sevalla databases activate sevalla databases suspend -sevalla databases reset-password +sevalla databases reset-password --new-password ``` ### Database Backups ```bash sevalla databases backups list --db-id -sevalla databases backups create +sevalla databases backups create --name nightly sevalla databases backups delete sevalla databases backups restore ``` @@ -225,7 +225,7 @@ sevalla databases backups restore ```bash sevalla databases connections internal-list --db-id -sevalla databases connections internal-create --application-id +sevalla databases connections internal-create --target-id sevalla databases connections internal-delete sevalla databases connections external-toggle ``` @@ -247,7 +247,7 @@ sevalla databases metrics used-storage ```bash sevalla databases ip-restriction get -sevalla databases ip-restriction update +sevalla databases ip-restriction update --type allow --ip-list "1.2.3.4" --enabled true ``` ### Static Sites @@ -277,7 +277,7 @@ sevalla static-sites deployments logs --site-id sevalla static-sites domains list --site-id sevalla static-sites domains get --site-id sevalla static-sites domains add --name example.com -sevalla static-sites domains update --site-id +sevalla static-sites domains update --site-id --name my-site sevalla static-sites domains delete sevalla static-sites domains set-primary sevalla static-sites domains toggle @@ -326,7 +326,7 @@ sevalla load-balancers delete ```bash sevalla load-balancers destinations list --lb-id -sevalla load-balancers destinations create --application-id +sevalla load-balancers destinations create --service-type APP --service-id sevalla load-balancers destinations toggle sevalla load-balancers destinations delete ``` @@ -337,7 +337,7 @@ sevalla load-balancers destinations delete sevalla load-balancers domains list --lb-id sevalla load-balancers domains get --lb-id sevalla load-balancers domains add --name example.com -sevalla load-balancers domains update --lb-id +sevalla load-balancers domains update --lb-id --name my-lb sevalla load-balancers domains delete sevalla load-balancers domains set-primary sevalla load-balancers domains toggle @@ -365,8 +365,8 @@ sevalla object-storage cdn-domain disable ```bash sevalla object-storage cors-policies list --bucket-id -sevalla object-storage cors-policies create -sevalla object-storage cors-policies update --bucket-id +sevalla object-storage cors-policies create --origins "*" --methods GET,HEAD +sevalla object-storage cors-policies update --bucket-id --origins "*" --methods GET sevalla object-storage cors-policies delete ``` @@ -374,7 +374,7 @@ sevalla object-storage cors-policies delete ```bash sevalla object-storage objects list --bucket-id -sevalla object-storage objects delete +sevalla object-storage objects delete --keys a.txt,b.txt ``` ### Pipelines @@ -385,7 +385,7 @@ sevalla pipelines get sevalla pipelines create --name mypipeline --type trunk sevalla pipelines update --name "My Pipeline" sevalla pipelines delete -sevalla pipelines promote +sevalla pipelines promote --source-app-id --target-app-ids , ``` ### Pipeline Stages @@ -402,7 +402,7 @@ sevalla pipelines stages apps remove ```bash sevalla pipelines preview enable sevalla pipelines preview disable -sevalla pipelines preview update +sevalla pipelines preview update --auto-create-app true ``` ### Projects @@ -413,7 +413,7 @@ sevalla projects get sevalla projects create --name myproject sevalla projects update --name "My Project" sevalla projects delete -sevalla projects services add +sevalla projects services add --service-id sevalla projects services remove ``` diff --git a/src/client/api-client.ts b/src/client/api-client.ts index d631b4d..bee3f7f 100644 --- a/src/client/api-client.ts +++ b/src/client/api-client.ts @@ -69,7 +69,9 @@ export class ApiClient { ); } - return (await response.json()) as T; + // Some endpoints answer 202 with no body (e.g. object storage delete) + const text = await response.text(); + return (text ? JSON.parse(text) : undefined) as T; } async get(path: string, query?: QueryParams): Promise { diff --git a/src/client/endpoints/applications.ts b/src/client/endpoints/applications.ts index 24416d8..ac1e1d6 100644 --- a/src/client/endpoints/applications.ts +++ b/src/client/endpoints/applications.ts @@ -5,7 +5,7 @@ import type { QueryParams } from '../types.ts'; // Path prefix // --------------------------------------------------------------------------- -const BASE = '/v3/applications'; +const BASE = '/applications'; // --------------------------------------------------------------------------- // Query helpers diff --git a/src/commands/apps/clone.ts b/src/commands/apps/clone.ts index 6aaa7f2..aeedb62 100644 --- a/src/commands/apps/clone.ts +++ b/src/commands/apps/clone.ts @@ -4,12 +4,15 @@ export const appsCloneCommand = makeActionCommand({ name: 'clone', description: 'Clone an application', options: [ - { flags: '--display-name ', description: 'Display name for the cloned application' }, + { + flags: '--display-name ', + description: 'Display name for the cloned application', + required: true, + }, { flags: '--cluster ', description: 'Cluster ID for the cloned application' }, ], apiCall: (client, id, opts) => { - const body: Record = {}; - if (opts['displayName']) body['display_name'] = opts['displayName']; + const body: Record = { display_name: opts['displayName'] }; if (opts['cluster']) body['cluster_id'] = opts['cluster']; return client.post(`/applications/${id}/clone`, body); }, diff --git a/src/commands/apps/deployments/rollback.ts b/src/commands/apps/deployments/rollback.ts index 75a6f30..1753bd0 100644 --- a/src/commands/apps/deployments/rollback.ts +++ b/src/commands/apps/deployments/rollback.ts @@ -2,15 +2,9 @@ import { makeActionCommand } from '../../../helpers/command-factory.ts'; export const deploymentsRollbackCommand = makeActionCommand({ name: 'rollback', - description: 'Rollback to a previous deployment', + description: 'Rollback to the most recent successful deployment', idArg: 'app-id', - options: [ - { flags: '--deployment-id ', description: 'Deployment ID to rollback to', required: true }, - ], - apiCall: (client, id, opts) => - client.post(`/applications/${id}/deployments/rollback`, { - deployment_id: opts['deploymentId'], - }), + apiCall: (client, id) => client.post(`/applications/${id}/deployments/rollback`), successMessage: 'Rollback initiated.', spinnerText: 'Rolling back...', }); diff --git a/src/commands/apps/deployments/trigger.ts b/src/commands/apps/deployments/trigger.ts index 12c380a..1363d9c 100644 --- a/src/commands/apps/deployments/trigger.ts +++ b/src/commands/apps/deployments/trigger.ts @@ -4,14 +4,10 @@ export const deploymentsTriggerCommand = makeActionCommand({ name: 'trigger', description: 'Trigger a new deployment', idArg: 'app-id', - options: [ - { flags: '--branch ', description: 'Branch to deploy' }, - { flags: '--tag ', description: 'Tag to deploy' }, - ], + options: [{ flags: '--branch ', description: 'Branch to deploy' }], apiCall: (client, id, opts) => { const body: Record = {}; if (opts['branch']) body['branch'] = opts['branch']; - if (opts['tag']) body['tag'] = opts['tag']; return client.post(`/applications/${id}/deployments`, body); }, successMessage: 'Deployment triggered.', diff --git a/src/commands/apps/domains/add.ts b/src/commands/apps/domains/add.ts index 10f143c..c6563c8 100644 --- a/src/commands/apps/domains/add.ts +++ b/src/commands/apps/domains/add.ts @@ -5,6 +5,7 @@ export const domainsAddCommand = makeActionCommand({ description: 'Add a domain to an application', idArg: 'app-id', options: [{ flags: '--name ', description: 'Domain name', required: true }], - apiCall: (client, id, opts) => client.post(`/applications/${id}/domains`, { name: opts['name'] }), + apiCall: (client, id, opts) => + client.post(`/applications/${id}/domains`, { domain_name: opts['name'] }), successMessage: 'Domain added.', }); diff --git a/src/commands/apps/domains/update.ts b/src/commands/apps/domains/update.ts index 5713dbe..4b5ab46 100644 --- a/src/commands/apps/domains/update.ts +++ b/src/commands/apps/domains/update.ts @@ -9,13 +9,16 @@ export const domainsUpdateCommand = new Command('update') .description('Update application system domain') .requiredOption('--app-id ', 'Application ID') .argument('', 'Domain ID') + .requiredOption('--name ', 'New subdomain name for the system domain') .addOption(jsonOption()) .action(async (domainId: string, opts: Record) => { const client = createClient({ apiUrl: opts['apiUrl'] as string | undefined }); const json = resolveJsonMode(opts); try { if (!json) startSpinner('Updating...'); - const result = await client.patch(`/applications/${opts['appId']}/domains/${domainId}`); + const result = await client.patch(`/applications/${opts['appId']}/domains/${domainId}`, { + name: opts['name'], + }); stopSpinner(); if (json) { printJson(result); diff --git a/src/commands/apps/ip-restriction/update.ts b/src/commands/apps/ip-restriction/update.ts index 9b76ad1..c34c65f 100644 --- a/src/commands/apps/ip-restriction/update.ts +++ b/src/commands/apps/ip-restriction/update.ts @@ -8,18 +8,19 @@ import { startSpinner, stopSpinner } from '../../../output/spinner.ts'; export const ipRestrictionUpdateCommand = new Command('update') .description('Update IP restriction rules') .argument('', 'Application ID') - .option('--type ', 'Restriction type: allow or deny') - .option('--ip-list ', 'Comma-separated IP addresses/CIDRs') - .option('--enabled ', 'Enable or disable') + .requiredOption('--type ', 'Restriction type: allow or deny') + .requiredOption('--ip-list ', 'Comma-separated IP addresses/CIDRs') + .requiredOption('--enabled ', 'Enable or disable (true/false)') .addOption(jsonOption()) .action(async (appId: string, opts: Record) => { const client = createClient({ apiUrl: opts['apiUrl'] as string | undefined }); const json = resolveJsonMode(opts); try { - const body: Record = {}; - if (opts['type']) body['type'] = opts['type']; - if (opts['ipList']) body['ip_list'] = (opts['ipList'] as string).split(','); - if (opts['enabled'] !== undefined) body['is_enabled'] = opts['enabled'] === 'true'; + const body = { + type: opts['type'], + ip_list: (opts['ipList'] as string).split(',').filter(Boolean), + is_enabled: opts['enabled'] === 'true', + }; if (!json) startSpinner('Updating...'); const result = await client.put(`/applications/${appId}/ip-restriction`, body); stopSpinner(); diff --git a/src/commands/apps/private-ports/create.ts b/src/commands/apps/private-ports/create.ts index 15897a2..a91ef5b 100644 --- a/src/commands/apps/private-ports/create.ts +++ b/src/commands/apps/private-ports/create.ts @@ -5,13 +5,13 @@ export const privatePortsCreateCommand = makeActionCommand({ description: 'Create a private port', idArg: 'app-id', options: [ + { flags: '--process-id ', description: 'Process ID', required: true }, { flags: '--port ', description: 'Port number', required: true }, - { flags: '--protocol ', description: 'Protocol (TCP/UDP)' }, ], - apiCall: (client, id, opts) => { - const body: Record = { port: Number(opts['port']) }; - if (opts['protocol']) body['protocol'] = opts['protocol']; - return client.post(`/applications/${id}/private-ports`, body); - }, + apiCall: (client, id, opts) => + client.post(`/applications/${id}/private-ports`, { + process_id: opts['processId'], + port: Number(opts['port']), + }), successMessage: 'Private port created.', }); diff --git a/src/commands/apps/processes/create.ts b/src/commands/apps/processes/create.ts index 5cb274a..a3f0937 100644 --- a/src/commands/apps/processes/create.ts +++ b/src/commands/apps/processes/create.ts @@ -6,19 +6,38 @@ export const processesCreateCommand = makeActionCommand({ idArg: 'app-id', options: [ { flags: '--name ', description: 'Process name', required: true }, - { flags: '--type ', description: 'Process type', required: true }, + { + flags: '--type ', + description: 'Process type: web, worker, cron or job', + required: true, + }, + { + flags: '--resource-type ', + description: 'Process resource type ID (see `sevalla resources process-types`)', + required: true, + }, { flags: '--command ', description: 'Start command' }, - { flags: '--resource-type ', description: 'Resource type' }, - { flags: '--instances ', description: 'Instance count' }, + { flags: '--instances ', description: 'Instance count (default: 1)' }, + { flags: '--schedule ', description: 'Cron expression (required for cron processes)' }, + { + flags: '--job-start-policy ', + description: + 'When a job runs: beforeDeployment, afterSuccessDeployment or afterFailedDeployment (required for job processes)', + }, ], apiCall: (client, id, opts) => { const body: Record = { - name: opts['name'], + display_name: opts['name'], type: opts['type'], + resource_type_id: opts['resourceType'], + scaling_strategy: { + type: 'manual', + config: { instanceCount: Number(opts['instances'] ?? 1) }, + }, }; - if (opts['command']) body['command'] = opts['command']; - if (opts['resourceType']) body['resource_type'] = opts['resourceType']; - if (opts['instances']) body['instances'] = Number(opts['instances']); + if (opts['command']) body['entrypoint'] = opts['command']; + if (opts['schedule']) body['schedule'] = opts['schedule']; + if (opts['jobStartPolicy']) body['job_start_policy'] = opts['jobStartPolicy']; return client.post(`/applications/${id}/processes`, body); }, successMessage: 'Process created.', diff --git a/src/commands/apps/processes/metrics/cpu-limit.ts b/src/commands/apps/processes/metrics/cpu-limit.ts index 1363769..ca24133 100644 --- a/src/commands/apps/processes/metrics/cpu-limit.ts +++ b/src/commands/apps/processes/metrics/cpu-limit.ts @@ -5,6 +5,8 @@ export const cpuLimitCommand = makeMetricsCommand({ description: 'Get process CPU limit metrics', label: 'CPU Limit', parentIds: ['app-id', 'process-id'], - apiCall: (client, compositeId, query) => - client.get(`/applications/${compositeId}/metrics/cpu-limit`, query), + apiCall: (client, compositeId, query) => { + const [appId, processId] = compositeId.split('/'); + return client.get(`/applications/${appId}/processes/${processId}/metrics/cpu-limit`, query); + }, }); diff --git a/src/commands/apps/processes/metrics/cpu-usage.ts b/src/commands/apps/processes/metrics/cpu-usage.ts index c63b886..8a8c627 100644 --- a/src/commands/apps/processes/metrics/cpu-usage.ts +++ b/src/commands/apps/processes/metrics/cpu-usage.ts @@ -5,6 +5,8 @@ export const cpuUsageCommand = makeMetricsCommand({ description: 'Get process CPU usage metrics', label: 'CPU Usage', parentIds: ['app-id', 'process-id'], - apiCall: (client, compositeId, query) => - client.get(`/applications/${compositeId}/metrics/cpu-usage`, query), + apiCall: (client, compositeId, query) => { + const [appId, processId] = compositeId.split('/'); + return client.get(`/applications/${appId}/processes/${processId}/metrics/cpu-usage`, query); + }, }); diff --git a/src/commands/apps/processes/metrics/instance-count.ts b/src/commands/apps/processes/metrics/instance-count.ts index 03c7cc7..7230433 100644 --- a/src/commands/apps/processes/metrics/instance-count.ts +++ b/src/commands/apps/processes/metrics/instance-count.ts @@ -5,6 +5,11 @@ export const instanceCountCommand = makeMetricsCommand({ description: 'Get process instance count metrics', label: 'Instance Count', parentIds: ['app-id', 'process-id'], - apiCall: (client, compositeId, query) => - client.get(`/applications/${compositeId}/metrics/instance-count`, query), + apiCall: (client, compositeId, query) => { + const [appId, processId] = compositeId.split('/'); + return client.get( + `/applications/${appId}/processes/${processId}/metrics/instance-count`, + query, + ); + }, }); diff --git a/src/commands/apps/processes/metrics/memory-limit.ts b/src/commands/apps/processes/metrics/memory-limit.ts index ab58e11..e9ab19b 100644 --- a/src/commands/apps/processes/metrics/memory-limit.ts +++ b/src/commands/apps/processes/metrics/memory-limit.ts @@ -5,6 +5,8 @@ export const memoryLimitCommand = makeMetricsCommand({ description: 'Get process memory limit metrics', label: 'Memory Limit', parentIds: ['app-id', 'process-id'], - apiCall: (client, compositeId, query) => - client.get(`/applications/${compositeId}/metrics/memory-limit`, query), + apiCall: (client, compositeId, query) => { + const [appId, processId] = compositeId.split('/'); + return client.get(`/applications/${appId}/processes/${processId}/metrics/memory-limit`, query); + }, }); diff --git a/src/commands/apps/processes/metrics/memory-usage.ts b/src/commands/apps/processes/metrics/memory-usage.ts index 609dec5..0efb68a 100644 --- a/src/commands/apps/processes/metrics/memory-usage.ts +++ b/src/commands/apps/processes/metrics/memory-usage.ts @@ -5,6 +5,8 @@ export const memoryUsageCommand = makeMetricsCommand({ description: 'Get process memory usage metrics', label: 'Memory Usage', parentIds: ['app-id', 'process-id'], - apiCall: (client, compositeId, query) => - client.get(`/applications/${compositeId}/metrics/memory-usage`, query), + apiCall: (client, compositeId, query) => { + const [appId, processId] = compositeId.split('/'); + return client.get(`/applications/${appId}/processes/${processId}/metrics/memory-usage`, query); + }, }); diff --git a/src/commands/apps/processes/update.ts b/src/commands/apps/processes/update.ts index c0c7a5f..73e6d23 100644 --- a/src/commands/apps/processes/update.ts +++ b/src/commands/apps/processes/update.ts @@ -11,7 +11,7 @@ export const processesUpdateCommand = new Command('update') .argument('', 'Process ID') .option('--name ', 'Process name') .option('--command ', 'Start command') - .option('--resource-type ', 'Resource type') + .option('--resource-type ', 'Process resource type ID') .option('--instances ', 'Instance count') .addOption(jsonOption()) .action(async (processId: string, opts: Record) => { @@ -19,10 +19,14 @@ export const processesUpdateCommand = new Command('update') const json = resolveJsonMode(opts); try { const body: Record = {}; - if (opts['name']) body['name'] = opts['name']; - if (opts['command']) body['command'] = opts['command']; - if (opts['resourceType']) body['resource_type'] = opts['resourceType']; - if (opts['instances']) body['instances'] = Number(opts['instances']); + if (opts['name']) body['display_name'] = opts['name']; + if (opts['command']) body['entrypoint'] = opts['command']; + if (opts['resourceType']) body['resource_type_id'] = opts['resourceType']; + if (opts['instances']) + body['scaling_strategy'] = { + type: 'manual', + config: { instanceCount: Number(opts['instances']) }, + }; if (!json) startSpinner('Updating...'); const result = await client.patch( `/applications/${opts['appId']}/processes/${processId}`, diff --git a/src/commands/apps/tcp-proxies/create.ts b/src/commands/apps/tcp-proxies/create.ts index 69a1230..ed88235 100644 --- a/src/commands/apps/tcp-proxies/create.ts +++ b/src/commands/apps/tcp-proxies/create.ts @@ -4,8 +4,14 @@ export const tcpProxiesCreateCommand = makeActionCommand({ name: 'create', description: 'Create a TCP proxy', idArg: 'app-id', - options: [{ flags: '--port ', description: 'Port number', required: true }], + options: [ + { flags: '--process-id ', description: 'Process ID', required: true }, + { flags: '--port ', description: 'Port number', required: true }, + ], apiCall: (client, id, opts) => - client.post(`/applications/${id}/tcp-proxies`, { port: Number(opts['port']) }), + client.post(`/applications/${id}/tcp-proxies`, { + process_id: opts['processId'], + port: Number(opts['port']), + }), successMessage: 'TCP proxy created.', }); diff --git a/src/commands/databases/backups/create.ts b/src/commands/databases/backups/create.ts index 24ab7de..28950d0 100644 --- a/src/commands/databases/backups/create.ts +++ b/src/commands/databases/backups/create.ts @@ -4,7 +4,9 @@ export const backupsCreateCommand = makeActionCommand({ name: 'create', description: 'Create a database backup', idArg: 'db-id', - apiCall: (client, id) => client.post(`/databases/${id}/backups`), + options: [{ flags: '--name ', description: 'Backup name', required: true }], + apiCall: (client, id, opts) => + client.post(`/databases/${id}/backups`, { display_name: opts['name'] }), successMessage: 'Database backup created.', spinnerText: 'Creating backup...', }); diff --git a/src/commands/databases/backups/list.ts b/src/commands/databases/backups/list.ts index a758fba..c2ad0e1 100644 --- a/src/commands/databases/backups/list.ts +++ b/src/commands/databases/backups/list.ts @@ -10,4 +10,5 @@ export const backupsListCommand = makeListCommand({ { header: 'Created At', key: 'created_at' }, ], apiCall: (client, opts) => client.get(`/databases/${opts['dbId']}/backups`), + paginated: false, }); diff --git a/src/commands/databases/backups/restore.ts b/src/commands/databases/backups/restore.ts index e5228c2..03efb92 100644 --- a/src/commands/databases/backups/restore.ts +++ b/src/commands/databases/backups/restore.ts @@ -6,7 +6,7 @@ export const backupsRestoreCommand = makeSubResourceActionCommand({ parentIdName: 'db-id', childIdName: 'backup-id', apiCall: (client, parentId, childId) => - client.post(`/databases/${parentId}/backups/${childId}/restore`), + client.post(`/databases/${parentId}/backups/${childId}/restore`, {}), successMessage: 'Database backup restored.', spinnerText: 'Restoring backup...', }); diff --git a/src/commands/databases/connections/internal-create.ts b/src/commands/databases/connections/internal-create.ts index 830aeca..bc1b951 100644 --- a/src/commands/databases/connections/internal-create.ts +++ b/src/commands/databases/connections/internal-create.ts @@ -4,10 +4,14 @@ export const internalConnectionsCreateCommand = makeActionCommand({ name: 'internal-create', description: 'Create an internal database connection', idArg: 'db-id', - options: [{ flags: '--application-id ', description: 'Application ID', required: true }], + options: [ + { flags: '--target-id ', description: 'Application or database ID', required: true }, + { flags: '--target-type ', description: 'Target type: app or database (default: app)' }, + ], apiCall: (client, id, opts) => client.post(`/databases/${id}/internal-connections`, { - application_id: opts['applicationId'], + target_id: opts['targetId'], + target_type: opts['targetType'] ?? 'app', }), successMessage: 'Internal connection created.', spinnerText: 'Creating internal connection...', diff --git a/src/commands/databases/connections/internal-list.ts b/src/commands/databases/connections/internal-list.ts index d9d6121..0a28f39 100644 --- a/src/commands/databases/connections/internal-list.ts +++ b/src/commands/databases/connections/internal-list.ts @@ -10,4 +10,5 @@ export const internalConnectionsListCommand = makeListCommand({ { header: 'Status', key: 'status' }, ], apiCall: (client, opts) => client.get(`/databases/${opts['dbId']}/internal-connections`), + paginated: false, }); diff --git a/src/commands/databases/ip-restriction/update.ts b/src/commands/databases/ip-restriction/update.ts index 0a4283b..1bebb6e 100644 --- a/src/commands/databases/ip-restriction/update.ts +++ b/src/commands/databases/ip-restriction/update.ts @@ -8,18 +8,19 @@ import { startSpinner, stopSpinner } from '../../../output/spinner.ts'; export const ipRestrictionUpdateCommand = new Command('update') .description('Update IP restriction rules') .argument('', 'Database ID') - .option('--allow ', 'Comma-separated allowed IPs') - .option('--deny ', 'Comma-separated denied IPs') - .option('--enabled ', 'Enable or disable') + .requiredOption('--type ', 'Restriction type: allow or deny') + .requiredOption('--ip-list ', 'Comma-separated IP addresses/CIDRs') + .requiredOption('--enabled ', 'Enable or disable (true/false)') .addOption(jsonOption()) .action(async (dbId: string, opts: Record) => { const client = createClient({ apiUrl: opts['apiUrl'] as string | undefined }); const json = resolveJsonMode(opts); try { - const body: Record = {}; - if (opts['allow']) body['allow'] = (opts['allow'] as string).split(','); - if (opts['deny']) body['deny'] = (opts['deny'] as string).split(','); - if (opts['enabled'] !== undefined) body['enabled'] = opts['enabled'] === 'true'; + const body = { + type: opts['type'], + ip_list: (opts['ipList'] as string).split(',').filter(Boolean), + is_enabled: opts['enabled'] === 'true', + }; if (!json) startSpinner('Updating...'); const result = await client.put(`/databases/${dbId}/ip-restriction`, body); stopSpinner(); diff --git a/src/commands/databases/metrics/all-storage.ts b/src/commands/databases/metrics/all-storage.ts index 2c26760..f138b33 100644 --- a/src/commands/databases/metrics/all-storage.ts +++ b/src/commands/databases/metrics/all-storage.ts @@ -1,8 +1,8 @@ -import { makeMetricsCommand } from '../../../helpers/command-factory.ts'; +import { makeGetCommand } from '../../../helpers/command-factory.ts'; -export const allStorageCommand = makeMetricsCommand({ +export const allStorageCommand = makeGetCommand({ name: 'all-storage', - description: 'Get database all storage metrics', - label: 'All Storage', - apiCall: (client, id, query) => client.get(`/databases/${id}/metrics/all-storage`, query), + description: 'Get database all storage metric', + displayFields: (item: { value: number }) => ({ 'All Storage': item.value }), + apiCall: (client, id) => client.get(`/databases/${id}/metrics/all-storage`), }); diff --git a/src/commands/databases/metrics/used-storage.ts b/src/commands/databases/metrics/used-storage.ts index c4cd3d1..7aa98ba 100644 --- a/src/commands/databases/metrics/used-storage.ts +++ b/src/commands/databases/metrics/used-storage.ts @@ -1,8 +1,8 @@ -import { makeMetricsCommand } from '../../../helpers/command-factory.ts'; +import { makeGetCommand } from '../../../helpers/command-factory.ts'; -export const usedStorageCommand = makeMetricsCommand({ +export const usedStorageCommand = makeGetCommand({ name: 'used-storage', - description: 'Get database used storage metrics', - label: 'Used Storage', - apiCall: (client, id, query) => client.get(`/databases/${id}/metrics/used-storage`, query), + description: 'Get database used storage metric', + displayFields: (item: { value: number }) => ({ 'Used Storage': item.value }), + apiCall: (client, id) => client.get(`/databases/${id}/metrics/used-storage`), }); diff --git a/src/commands/databases/reset-password.ts b/src/commands/databases/reset-password.ts index a85f3f5..e1e6245 100644 --- a/src/commands/databases/reset-password.ts +++ b/src/commands/databases/reset-password.ts @@ -3,7 +3,9 @@ import { makeActionCommand } from '../../helpers/command-factory.ts'; export const databasesResetPasswordCommand = makeActionCommand({ name: 'reset-password', description: 'Reset database password', - apiCall: (client, id) => client.post(`/databases/${id}/reset-password`), + options: [{ flags: '--new-password ', description: 'New password', required: true }], + apiCall: (client, id, opts) => + client.post(`/databases/${id}/reset-password`, { new_password: opts['newPassword'] }), successMessage: 'Database password reset.', spinnerText: 'Resetting database password...', }); diff --git a/src/commands/git/providers.ts b/src/commands/git/providers.ts index e49b7ce..a6873d3 100644 --- a/src/commands/git/providers.ts +++ b/src/commands/git/providers.ts @@ -9,4 +9,5 @@ export const gitProvidersCommand = makeListCommand({ { header: 'Type', key: 'type' }, ], apiCall: (client) => client.get('/git/providers'), + paginated: false, }); diff --git a/src/commands/load-balancers/destinations/create.ts b/src/commands/load-balancers/destinations/create.ts index 1296bf3..d71fe19 100644 --- a/src/commands/load-balancers/destinations/create.ts +++ b/src/commands/load-balancers/destinations/create.ts @@ -5,13 +5,24 @@ export const destinationsCreateCommand = makeActionCommand({ description: 'Create a load balancer destination', idArg: 'lb-id', options: [ - { flags: '--application-id ', description: 'Application ID' }, - { flags: '--port ', description: 'Port number' }, + { + flags: '--service-type ', + description: 'Service type: APP, STATIC_SITE, OBJECT_STORAGE, EXTERNAL or LOAD_BALANCER', + required: true, + }, + { flags: '--service-id ', description: 'Service ID (not used for EXTERNAL)' }, + { flags: '--url ', description: 'External URL (required for EXTERNAL)' }, + { flags: '--weight ', description: 'Traffic weight for weighted routing' }, + { flags: '--latitude ', description: 'Latitude for GEO routing' }, + { flags: '--longitude ', description: 'Longitude for GEO routing' }, ], apiCall: (client, id, opts) => { - const body: Record = {}; - if (opts['applicationId']) body['application_id'] = opts['applicationId']; - if (opts['port']) body['port'] = Number(opts['port']); + const body: Record = { service_type: opts['serviceType'] }; + if (opts['serviceId']) body['service_id'] = opts['serviceId']; + if (opts['url']) body['url'] = opts['url']; + if (opts['weight'] !== undefined) body['weight'] = Number(opts['weight']); + if (opts['latitude'] !== undefined) body['latitude'] = Number(opts['latitude']); + if (opts['longitude'] !== undefined) body['longitude'] = Number(opts['longitude']); return client.post(`/load-balancers/${id}/destinations`, body); }, successMessage: 'Destination created.', diff --git a/src/commands/load-balancers/domains/add.ts b/src/commands/load-balancers/domains/add.ts index cec91e9..77494ac 100644 --- a/src/commands/load-balancers/domains/add.ts +++ b/src/commands/load-balancers/domains/add.ts @@ -6,6 +6,6 @@ export const domainsAddCommand = makeActionCommand({ idArg: 'lb-id', options: [{ flags: '--name ', description: 'Domain name', required: true }], apiCall: (client, id, opts) => - client.post(`/load-balancers/${id}/domains`, { name: opts['name'] }), + client.post(`/load-balancers/${id}/domains`, { domain_name: opts['name'] }), successMessage: 'Domain added.', }); diff --git a/src/commands/load-balancers/domains/update.ts b/src/commands/load-balancers/domains/update.ts index 752c8a8..081f7d0 100644 --- a/src/commands/load-balancers/domains/update.ts +++ b/src/commands/load-balancers/domains/update.ts @@ -9,13 +9,16 @@ export const domainsUpdateCommand = new Command('update') .description('Update load balancer system domain') .requiredOption('--lb-id ', 'Load balancer ID') .argument('', 'Domain ID') + .requiredOption('--name ', 'New subdomain name for the system domain') .addOption(jsonOption()) .action(async (domainId: string, opts: Record) => { const client = createClient({ apiUrl: opts['apiUrl'] as string | undefined }); const json = resolveJsonMode(opts); try { if (!json) startSpinner('Updating...'); - const result = await client.patch(`/load-balancers/${opts['lbId']}/domains/${domainId}`); + const result = await client.patch(`/load-balancers/${opts['lbId']}/domains/${domainId}`, { + name: opts['name'], + }); stopSpinner(); if (json) { printJson(result); diff --git a/src/commands/object-storage/cors-policies/create.ts b/src/commands/object-storage/cors-policies/create.ts index 5a66d2b..6fb36ae 100644 --- a/src/commands/object-storage/cors-policies/create.ts +++ b/src/commands/object-storage/cors-policies/create.ts @@ -5,14 +5,23 @@ export const corsPoliciesCreateCommand = makeActionCommand({ description: 'Create a CORS policy', idArg: 'bucket-id', options: [ - { flags: '--origins ', description: 'Allowed origins' }, - { flags: '--methods ', description: 'Allowed methods' }, - { flags: '--headers ', description: 'Allowed headers' }, + { + flags: '--origins ', + description: 'Comma-separated allowed origins', + required: true, + }, + { + flags: '--methods ', + description: 'Comma-separated allowed methods (GET, PUT, POST, DELETE, HEAD)', + required: true, + }, + { flags: '--headers ', description: 'Comma-separated allowed headers' }, ], apiCall: (client, id, opts) => { - const body: Record = {}; - if (opts['origins']) body['origins'] = (opts['origins'] as string).split(','); - if (opts['methods']) body['methods'] = (opts['methods'] as string).split(','); + const body: Record = { + origins: (opts['origins'] as string).split(','), + methods: (opts['methods'] as string).split(','), + }; if (opts['headers']) body['headers'] = (opts['headers'] as string).split(','); return client.post(`/object-storage/${id}/cors-policies`, body); }, diff --git a/src/commands/object-storage/cors-policies/list.ts b/src/commands/object-storage/cors-policies/list.ts index a412ab4..4564429 100644 --- a/src/commands/object-storage/cors-policies/list.ts +++ b/src/commands/object-storage/cors-policies/list.ts @@ -11,4 +11,5 @@ export const corsPoliciesListCommand = makeListCommand({ ], apiCall: (client, opts) => client.get(`/object-storage/${opts['bucketId']}/cors-policies`), parentIdFlag: { name: 'bucket-id', description: 'Bucket ID' }, + paginated: false, }); diff --git a/src/commands/object-storage/cors-policies/update.ts b/src/commands/object-storage/cors-policies/update.ts index 7dc0c39..d21b8a1 100644 --- a/src/commands/object-storage/cors-policies/update.ts +++ b/src/commands/object-storage/cors-policies/update.ts @@ -9,18 +9,22 @@ export const corsPoliciesUpdateCommand = new Command('update') .description('Update a CORS policy') .requiredOption('--bucket-id ', 'Bucket ID') .argument('', 'CORS policy ID') - .option('--origins ', 'Allowed origins') - .option('--methods ', 'Allowed methods') - .option('--headers ', 'Allowed headers') + .requiredOption('--origins ', 'Comma-separated allowed origins') + .requiredOption( + '--methods ', + 'Comma-separated allowed methods (GET, PUT, POST, DELETE, HEAD)', + ) + .option('--headers ', 'Comma-separated allowed headers') .addOption(jsonOption()) .action(async (policyId: string, opts: Record) => { const client = createClient({ apiUrl: opts['apiUrl'] as string | undefined }); const json = resolveJsonMode(opts); try { - const body: Record = {}; - if (opts['origins']) body['origins'] = opts['origins']; - if (opts['methods']) body['methods'] = opts['methods']; - if (opts['headers']) body['headers'] = opts['headers']; + const body: Record = { + origins: (opts['origins'] as string).split(','), + methods: (opts['methods'] as string).split(','), + }; + if (opts['headers']) body['headers'] = (opts['headers'] as string).split(','); if (!json) startSpinner('Updating...'); const result = await client.patch( `/object-storage/${opts['bucketId']}/cors-policies/${policyId}`, diff --git a/src/commands/object-storage/objects/delete.ts b/src/commands/object-storage/objects/delete.ts index ec7d528..7bd39e2 100644 --- a/src/commands/object-storage/objects/delete.ts +++ b/src/commands/object-storage/objects/delete.ts @@ -4,8 +4,14 @@ export const objectsDeleteCommand = makeActionCommand({ name: 'delete', description: 'Delete objects from a bucket', idArg: 'bucket-id', - options: [{ flags: '--keys ', description: 'Object keys to delete' }], + options: [ + { + flags: '--keys ', + description: 'Comma-separated object keys to delete', + required: true, + }, + ], apiCall: (client, id, opts) => - client.delete(`/object-storage/${id}/objects`, { keys: opts['keys'] }), + client.delete(`/object-storage/${id}/objects`, { keys: (opts['keys'] as string).split(',') }), successMessage: 'Objects deleted.', }); diff --git a/src/commands/object-storage/objects/list.ts b/src/commands/object-storage/objects/list.ts index a8c78bb..b282f25 100644 --- a/src/commands/object-storage/objects/list.ts +++ b/src/commands/object-storage/objects/list.ts @@ -10,4 +10,5 @@ export const objectsListCommand = makeListCommand({ ], apiCall: (client, opts) => client.get(`/object-storage/${opts['bucketId']}/objects`), parentIdFlag: { name: 'bucket-id', description: 'Bucket ID' }, + paginated: false, }); diff --git a/src/commands/pipelines/preview/enable.ts b/src/commands/pipelines/preview/enable.ts index 8ecdd11..be31df9 100644 --- a/src/commands/pipelines/preview/enable.ts +++ b/src/commands/pipelines/preview/enable.ts @@ -1,9 +1,11 @@ import { makeActionCommand } from '../../../helpers/command-factory.ts'; +import { previewBody, previewOptions } from './update.ts'; export const previewEnableCommand = makeActionCommand({ name: 'enable', description: 'Enable pipeline preview', idArg: 'pipeline-id', - apiCall: (client, id) => client.post(`/pipelines/${id}/preview/enable`), + options: previewOptions, + apiCall: (client, id, opts) => client.post(`/pipelines/${id}/preview/enable`, previewBody(opts)), successMessage: 'Pipeline preview enabled.', }); diff --git a/src/commands/pipelines/preview/update.ts b/src/commands/pipelines/preview/update.ts index a4a1bd4..80609c3 100644 --- a/src/commands/pipelines/preview/update.ts +++ b/src/commands/pipelines/preview/update.ts @@ -1,17 +1,34 @@ import { makeUpdateCommand } from '../../../helpers/command-factory.ts'; +// Shared with `preview enable`, which takes the same body +export const previewOptions = [ + { flags: '--repo-url ', description: 'Git repository URL' }, + { + flags: '--auto-create-app ', + description: 'Create a preview app when a pull request is opened (true/false)', + }, + { flags: '--delete-stale-apps ', description: 'Delete stale preview apps (true/false)' }, + { + flags: '--stale-app-days ', + description: 'Days without deployment before an app is stale', + }, +]; + +export function previewBody(opts: Record): Record { + const body: Record = {}; + if (opts['repoUrl']) body['repo_url'] = opts['repoUrl']; + if (opts['autoCreateApp'] !== undefined) + body['auto_create_app'] = opts['autoCreateApp'] === 'true'; + if (opts['deleteStaleApps'] !== undefined) + body['delete_stale_apps'] = opts['deleteStaleApps'] === 'true'; + if (opts['staleAppDays'] !== undefined) body['stale_app_days'] = Number(opts['staleAppDays']); + return body; +} + export const previewUpdateCommand = makeUpdateCommand({ name: 'update', description: 'Update pipeline preview settings', idArg: 'pipeline-id', - options: [ - { flags: '--branch ', description: 'Preview branch' }, - { flags: '--auto-deploy', description: 'Enable auto deploy' }, - ], - apiCall: (client, id, opts) => { - const body: Record = {}; - if (opts['branch']) body['branch'] = opts['branch']; - if (opts['autoDeploy'] !== undefined) body['auto_deploy'] = opts['autoDeploy']; - return client.put(`/pipelines/${id}/preview`, body); - }, + options: previewOptions, + apiCall: (client, id, opts) => client.put(`/pipelines/${id}/preview`, previewBody(opts)), }); diff --git a/src/commands/pipelines/promote.ts b/src/commands/pipelines/promote.ts index 8d51a40..474222d 100644 --- a/src/commands/pipelines/promote.ts +++ b/src/commands/pipelines/promote.ts @@ -3,7 +3,23 @@ import { makeActionCommand } from '../../helpers/command-factory.ts'; export const pipelinesPromoteCommand = makeActionCommand({ name: 'promote', description: 'Promote a pipeline', - apiCall: (client, id) => client.post(`/pipelines/${id}/promote`), + options: [ + { + flags: '--source-app-id ', + description: 'Application ID to promote from', + required: true, + }, + { + flags: '--target-app-ids ', + description: 'Comma-separated application IDs to promote to', + required: true, + }, + ], + apiCall: (client, id, opts) => + client.post(`/pipelines/${id}/promote`, { + source_app_id: opts['sourceAppId'], + target_app_ids: (opts['targetAppIds'] as string).split(','), + }), successMessage: 'Pipeline promoted.', spinnerText: 'Promoting pipeline...', }); diff --git a/src/commands/pipelines/stages/create.ts b/src/commands/pipelines/stages/create.ts index fba1779..c06f1ef 100644 --- a/src/commands/pipelines/stages/create.ts +++ b/src/commands/pipelines/stages/create.ts @@ -5,15 +5,14 @@ export const stagesCreateCommand = makeActionCommand({ description: 'Create a pipeline stage', idArg: 'pipeline-id', options: [ - { flags: '--name ', description: 'Stage name' }, + { flags: '--name ', description: 'Stage name', required: true }, { flags: '--insert-before ', description: 'Insert before this order position (default: 0)', }, ], apiCall: (client, id, opts) => { - const body: Record = {}; - if (opts['name']) body['display_name'] = opts['name']; + const body: Record = { display_name: opts['name'] }; if (opts['insertBefore'] !== undefined) body['insert_before'] = Number(opts['insertBefore']); else body['insert_before'] = 0; return client.post(`/pipelines/${id}/stages`, body); diff --git a/src/commands/projects/services/add.ts b/src/commands/projects/services/add.ts index 001113a..6cd5993 100644 --- a/src/commands/projects/services/add.ts +++ b/src/commands/projects/services/add.ts @@ -5,10 +5,10 @@ export const servicesAddCommand = makeActionCommand({ description: 'Add a service to a project', idArg: 'project-id', options: [ - { flags: '--service-id ', description: 'Service ID' }, + { flags: '--service-id ', description: 'Service ID', required: true }, { flags: '--service-type ', - description: 'Service type (e.g. app, database, static-site)', + description: 'Service type: app, database, pipeline or object-storage (default: app)', }, ], apiCall: (client, id, opts) => diff --git a/src/commands/projects/services/remove.ts b/src/commands/projects/services/remove.ts index 2793252..0eda5ce 100644 --- a/src/commands/projects/services/remove.ts +++ b/src/commands/projects/services/remove.ts @@ -15,7 +15,7 @@ export const servicesRemoveCommand = new Command('remove') .description('Remove a service from a project') .argument('', 'Project ID') .argument('', 'Service ID') - .option('--service-type ', 'Service type (e.g. app, database, static-site)', 'app') + .option('--service-type ', 'Service type: app, database, pipeline or object-storage', 'app') .addOption(jsonOption()) .addOption(confirmOption()) .addOption(dryRunOption()) diff --git a/src/commands/projects/update.ts b/src/commands/projects/update.ts index efee266..a4748ea 100644 --- a/src/commands/projects/update.ts +++ b/src/commands/projects/update.ts @@ -3,14 +3,10 @@ import { makeUpdateCommand } from '../../helpers/command-factory.ts'; export const projectsUpdateCommand = makeUpdateCommand({ name: 'update', description: 'Update a project', - options: [{ flags: '--name ', description: 'Project name' }], + options: [{ flags: '--name ', description: 'Project name', required: true }], displayFields: (item: Record) => ({ ID: item['id'], Name: item['display_name'], }), - apiCall: (client, id, opts) => { - const body: Record = {}; - if (opts['name']) body['display_name'] = opts['name']; - return client.patch(`/projects/${id}`, body); - }, + apiCall: (client, id, opts) => client.patch(`/projects/${id}`, { display_name: opts['name'] }), }); diff --git a/src/commands/resources/clusters.ts b/src/commands/resources/clusters.ts index 3a48d0b..066f4e1 100644 --- a/src/commands/resources/clusters.ts +++ b/src/commands/resources/clusters.ts @@ -9,4 +9,5 @@ export const resourcesClustersCommand = makeListCommand({ { header: 'Location', key: 'location' }, ], apiCall: (client) => client.get('/resources/clusters'), + paginated: false, }); diff --git a/src/commands/resources/db-types.ts b/src/commands/resources/db-types.ts index 807b618..591649a 100644 --- a/src/commands/resources/db-types.ts +++ b/src/commands/resources/db-types.ts @@ -8,4 +8,5 @@ export const resourcesDbTypesCommand = makeListCommand({ { header: 'Name', key: 'name' }, ], apiCall: (client) => client.get('/resources/database-resource-types'), + paginated: false, }); diff --git a/src/commands/resources/process-types.ts b/src/commands/resources/process-types.ts index 3ffd8d9..ab86917 100644 --- a/src/commands/resources/process-types.ts +++ b/src/commands/resources/process-types.ts @@ -8,4 +8,5 @@ export const resourcesProcessTypesCommand = makeListCommand({ { header: 'Name', key: 'name' }, ], apiCall: (client) => client.get('/resources/process-resource-types'), + paginated: false, }); diff --git a/src/commands/resources/rbac-permissions.ts b/src/commands/resources/rbac-permissions.ts index a394b8b..2124b02 100644 --- a/src/commands/resources/rbac-permissions.ts +++ b/src/commands/resources/rbac-permissions.ts @@ -11,4 +11,5 @@ export const resourcesRbacPermissionsCommand = makeListCommand({ { header: 'Description', key: 'description' }, ], apiCall: (client) => client.get('/resources/rbac/api-key-permissions'), + paginated: false, }); diff --git a/src/commands/resources/rbac-roles.ts b/src/commands/resources/rbac-roles.ts index cd140d2..ed4d5e6 100644 --- a/src/commands/resources/rbac-roles.ts +++ b/src/commands/resources/rbac-roles.ts @@ -9,4 +9,5 @@ export const resourcesRbacRolesCommand = makeListCommand({ { header: 'Description', key: 'description' }, ], apiCall: (client) => client.get('/resources/rbac/api-key-roles'), + paginated: false, }); diff --git a/src/commands/static-sites/deployments/trigger.ts b/src/commands/static-sites/deployments/trigger.ts index e8416a9..1abf87f 100644 --- a/src/commands/static-sites/deployments/trigger.ts +++ b/src/commands/static-sites/deployments/trigger.ts @@ -4,14 +4,10 @@ export const deploymentsTriggerCommand = makeActionCommand({ name: 'trigger', description: 'Trigger a new deployment', idArg: 'site-id', - options: [ - { flags: '--branch ', description: 'Branch to deploy' }, - { flags: '--tag ', description: 'Tag to deploy' }, - ], + options: [{ flags: '--branch ', description: 'Branch to deploy' }], apiCall: (client, id, opts) => { const body: Record = {}; if (opts['branch']) body['branch'] = opts['branch']; - if (opts['tag']) body['tag'] = opts['tag']; return client.post(`/static-sites/${id}/deployments`, body); }, successMessage: 'Deployment triggered.', diff --git a/src/commands/static-sites/domains/add.ts b/src/commands/static-sites/domains/add.ts index bb9d05d..e4ae58e 100644 --- a/src/commands/static-sites/domains/add.ts +++ b/src/commands/static-sites/domains/add.ts @@ -5,6 +5,7 @@ export const domainsAddCommand = makeActionCommand({ description: 'Add a domain to a static site', idArg: 'site-id', options: [{ flags: '--name ', description: 'Domain name', required: true }], - apiCall: (client, id, opts) => client.post(`/static-sites/${id}/domains`, { name: opts['name'] }), + apiCall: (client, id, opts) => + client.post(`/static-sites/${id}/domains`, { domain_name: opts['name'] }), successMessage: 'Domain added.', }); diff --git a/src/commands/static-sites/domains/update.ts b/src/commands/static-sites/domains/update.ts index b85af26..030b803 100644 --- a/src/commands/static-sites/domains/update.ts +++ b/src/commands/static-sites/domains/update.ts @@ -9,13 +9,16 @@ export const domainsUpdateCommand = new Command('update') .description('Update static site system domain') .requiredOption('--site-id ', 'Static site ID') .argument('', 'Domain ID') + .requiredOption('--name ', 'New subdomain name for the system domain') .addOption(jsonOption()) .action(async (domainId: string, opts: Record) => { const client = createClient({ apiUrl: opts['apiUrl'] as string | undefined }); const json = resolveJsonMode(opts); try { if (!json) startSpinner('Updating...'); - const result = await client.patch(`/static-sites/${opts['siteId']}/domains/${domainId}`); + const result = await client.patch(`/static-sites/${opts['siteId']}/domains/${domainId}`, { + name: opts['name'], + }); stopSpinner(); if (json) { printJson(result); diff --git a/src/commands/users/list.ts b/src/commands/users/list.ts index 6043a6c..1dc5a0e 100644 --- a/src/commands/users/list.ts +++ b/src/commands/users/list.ts @@ -10,4 +10,5 @@ export const usersListCommand = makeListCommand({ { header: 'Role', key: 'role' }, ], apiCall: (client) => client.get('/users'), + paginated: false, }); diff --git a/src/helpers/command-factory.ts b/src/helpers/command-factory.ts index 9116fdc..0649e6f 100644 --- a/src/helpers/command-factory.ts +++ b/src/helpers/command-factory.ts @@ -50,6 +50,8 @@ export interface ListCommandConfig { opts: Record, ) => Promise | T[]>; parentIdFlag?: { name: string; description: string }; + // false for endpoints that don't accept limit/offset + paginated?: boolean; } export function makeListCommand(config: ListCommandConfig): Command { @@ -60,8 +62,10 @@ export function makeListCommand(config: ListCommandConfig): Command { cmd.requiredOption(`--${config.parentIdFlag.name} `, config.parentIdFlag.description); } - for (const opt of paginationOptions()) { - cmd.addOption(opt); + if (config.paginated !== false) { + for (const opt of paginationOptions()) { + cmd.addOption(opt); + } } cmd.action(async (opts: Record) => { @@ -78,10 +82,8 @@ export function makeListCommand(config: ListCommandConfig): Command { // Convert page/perPage to limit/offset for the API const perPage = opts['perPage'] as number; const page = opts['page'] as number; - const paginationQuery: QueryParams = { - limit: perPage, - offset: (page - 1) * perPage, - }; + const paginationQuery: QueryParams = + config.paginated === false ? {} : { limit: perPage, offset: (page - 1) * perPage }; // Wrap client so apiCall's GET requests include pagination automatically const paginatedClient = new Proxy(client, { diff --git a/tests/e2e/helpers/cleanup.ts b/tests/e2e/helpers/cleanup.ts index c92e995..4d5716b 100644 --- a/tests/e2e/helpers/cleanup.ts +++ b/tests/e2e/helpers/cleanup.ts @@ -2,8 +2,8 @@ import { sevalla } from './cli.ts' const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) -async function cancelAppDeployments(id: string): Promise { - const nonTerminal = ['queued', 'pending', 'building', 'built', 'deploying', 'rolling_out', 'in_progress'] +export async function cancelAppDeployments(id: string): Promise { + const nonTerminal = ['waiting', 'inProgress'] const deploymentsRes = await sevalla(['apps', 'deployments', 'list', '--app-id', id]) if (deploymentsRes.exitCode !== 0) return try { diff --git a/tests/e2e/helpers/cli.ts b/tests/e2e/helpers/cli.ts index f671c5f..fccddd7 100644 --- a/tests/e2e/helpers/cli.ts +++ b/tests/e2e/helpers/cli.ts @@ -1,6 +1,6 @@ import { execFile } from 'node:child_process' -const CLI_PATH = new URL('../../dist/index.js', import.meta.url).pathname +const CLI_PATH = new URL('../../../dist/index.js', import.meta.url).pathname const API_KEY = process.env.API_KEY if (!API_KEY) { throw new Error('API_KEY environment variable is required') @@ -52,3 +52,8 @@ export function sevallaRaw(args: string[], opts?: { env?: Record }) }) } + +// For state-dependent calls: true when the API rejected the request shape rather than the resource state +export function rejectedBySchema(res: CliResult): boolean { + return res.stderr.includes("doesn't match the schema") +} diff --git a/tests/e2e/teardown.ts b/tests/e2e/teardown.ts index a0cbb67..b4bb17c 100644 --- a/tests/e2e/teardown.ts +++ b/tests/e2e/teardown.ts @@ -7,7 +7,7 @@ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) type ListItem = { id: string; display_name?: string; name?: string } async function cancelStaticSiteDeployments(siteId: string) { - const nonTerminal = ['queued', 'pending', 'building', 'built', 'deploying', 'rolling_out', 'in_progress'] + const nonTerminal = ['waiting', 'inProgress'] const res = await sevalla(['static-sites', 'deployments', 'list', '--site-id', siteId]) if (res.exitCode !== 0) return const deps = res.json<{ id: string; status?: string | null }[]>() diff --git a/tests/e2e/tests/application-deployments.test.ts b/tests/e2e/tests/application-deployments.test.ts index 9ed6cb1..84fee3c 100644 --- a/tests/e2e/tests/application-deployments.test.ts +++ b/tests/e2e/tests/application-deployments.test.ts @@ -1,7 +1,7 @@ import { describe, it, before, after } from 'node:test' import assert from 'node:assert/strict' -import { sevalla } from '../helpers/cli.ts' -import { safeDeleteApp, safeDelete } from '../helpers/cleanup.ts' +import { sevalla, rejectedBySchema } from '../helpers/cli.ts' +import { safeDeleteApp, safeDelete, cancelAppDeployments } from '../helpers/cleanup.ts' interface Cluster { id: string @@ -34,6 +34,7 @@ describe('application deployments and processes', () => { let clusterId: string let createdProjectId: string | undefined let createdAppId: string | undefined + let workerProcessId: string | undefined before(async () => { // Get a cluster ID @@ -135,6 +136,14 @@ describe('application deployments and processes', () => { assert.equal(res.exitCode, 0, `apps deployments cancel failed: ${res.stderr}`) }) + it('should send a rollback request the API accepts', async () => { + assert.ok(createdAppId, 'createdAppId must be set from before hook') + + // No deployment has succeeded yet, so the API may refuse on state, but never on request shape + const res = await sevalla(['apps', 'deployments', 'rollback', createdAppId], { timeout: 120_000 }) + assert.ok(!rejectedBySchema(res), `apps deployments rollback rejected by schema: ${res.stderr}`) + }) + // --- Processes --- it('should list processes', async () => { @@ -164,4 +173,99 @@ describe('application deployments and processes', () => { const proc = res.json() assert.equal(proc.id, processId, 'returned process id should match') }) + + it('should create a worker process', async () => { + assert.ok(createdAppId, 'createdAppId must be set from before hook') + + const typesRes = await sevalla(['resources', 'process-types']) + assert.equal(typesRes.exitCode, 0, `resources process-types failed: ${typesRes.stderr}`) + const types = typesRes.json<{ id: string }[]>() + assert.ok(types.length > 0, 'at least one process resource type must exist') + + const res = await sevalla([ + 'apps', 'processes', 'create', createdAppId, + '--name', `e2e-worker-${ts}`, + '--type', 'worker', + '--resource-type', (types[0] as { id: string }).id, + '--command', 'sleep infinity', + ]) + assert.equal(res.exitCode, 0, `apps processes create failed: ${res.stderr}`) + const proc = res.json() + assert.ok(proc.id, 'created process should have an id') + workerProcessId = proc.id + }) + + 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') + + const res = await sevalla([ + 'apps', 'processes', 'update', workerProcessId, + '--app-id', createdAppId, + '--name', `e2e-worker-updated-${ts}`, + '--command', 'sleep 3600', + '--instances', '2', + ]) + assert.equal(res.exitCode, 0, `apps processes update failed: ${res.stderr}`) + }) + + it('should get process metrics', async () => { + assert.ok(createdAppId, 'createdAppId must be set from before hook') + assert.ok(workerProcessId, 'workerProcessId must be set from create test') + + for (const metric of ['cpu-usage', 'cpu-limit', 'memory-usage', 'memory-limit', 'instance-count']) { + const res = await sevalla(['apps', 'processes', 'metrics', metric, createdAppId, workerProcessId]) + assert.equal(res.exitCode, 0, `apps processes metrics ${metric} failed: ${res.stderr}`) + assert.ok(Array.isArray(res.json()), `${metric} should return an array`) + } + }) + + it('should create and delete a TCP proxy', 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', 'tcp-proxies', 'create', createdAppId, + '--process-id', workerProcessId, + '--port', '6379', + ]) + assert.equal(res.exitCode, 0, `apps tcp-proxies create failed: ${res.stderr}`) + const proxy = res.json<{ id: string }>() + assert.ok(proxy.id, 'created TCP proxy should have an id') + + const delRes = await sevalla(['apps', 'tcp-proxies', 'delete', createdAppId, proxy.id, '--confirm']) + assert.equal(delRes.exitCode, 0, `apps tcp-proxies delete failed: ${delRes.stderr}`) + }) + + it('should create and delete a private port', 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', 'private-ports', 'create', createdAppId, + '--process-id', workerProcessId, + '--port', '8081', + ]) + assert.equal(res.exitCode, 0, `apps private-ports create failed: ${res.stderr}`) + const port = res.json<{ id: string }>() + assert.ok(port.id, 'created private port should have an id') + + const delRes = await sevalla(['apps', 'private-ports', 'delete', createdAppId, port.id, '--confirm']) + assert.equal(delRes.exitCode, 0, `apps private-ports delete failed: ${delRes.stderr}`) + }) + + it('should delete a process', async () => { + assert.ok(createdAppId, 'createdAppId must be set from before hook') + assert.ok(workerProcessId, 'workerProcessId must be set from create test') + + // Process changes start a deployment, and the API refuses to delete while one is running + let res = await sevalla(['apps', 'processes', 'delete', workerProcessId, '--app-id', createdAppId, '--confirm']) + for (let attempt = 0; attempt < 10 && res.exitCode !== 0; attempt++) { + await cancelAppDeployments(createdAppId) + await new Promise((resolve) => setTimeout(resolve, 3000)) + res = await sevalla(['apps', 'processes', 'delete', workerProcessId, '--app-id', createdAppId, '--confirm']) + } + assert.equal(res.exitCode, 0, `apps processes delete failed: ${res.stderr}`) + workerProcessId = undefined + }) }) diff --git a/tests/e2e/tests/application-settings.test.ts b/tests/e2e/tests/application-settings.test.ts index 75be51b..85f0376 100644 --- a/tests/e2e/tests/application-settings.test.ts +++ b/tests/e2e/tests/application-settings.test.ts @@ -33,6 +33,7 @@ interface Domain { name?: string status?: string is_primary?: boolean + type?: string [key: string]: unknown } @@ -178,6 +179,34 @@ describe('application settings', () => { assert.ok(Array.isArray(domains), 'domains response should be an array') }) + it('should add and delete a custom domain', async () => { + assert.ok(appId, 'appId must be set from before hook') + + const res = await sevalla(['apps', 'domains', 'add', appId, '--name', `e2e-${ts}.sevalla-cli-e2e.dev`]) + assert.equal(res.exitCode, 0, `domains add failed: ${res.stderr}`) + const domain = res.json() + assert.ok(domain.id, 'added domain should have an id') + + const delRes = await sevalla(['apps', 'domains', 'delete', appId, domain.id, '--confirm']) + assert.equal(delRes.exitCode, 0, `domains delete failed: ${delRes.stderr}`) + }) + + it('should rename the system domain', async () => { + assert.ok(appId, 'appId must be set from before hook') + + const listRes = await sevalla(['apps', 'domains', 'list', '--app-id', appId]) + assert.equal(listRes.exitCode, 0, `domains list failed: ${listRes.stderr}`) + const system = listRes.json().find((d) => d.type === 'SYSTEM') + assert.ok(system, 'app should have a system domain') + + const res = await sevalla([ + 'apps', 'domains', 'update', system.id, + '--app-id', appId, + '--name', `e2e-app-${ts}`, + ]) + assert.equal(res.exitCode, 0, `domains update failed: ${res.stderr}`) + }) + // Logs it('should get runtime logs', async () => { diff --git a/tests/e2e/tests/databases.test.ts b/tests/e2e/tests/databases.test.ts index 92d316c..40d6454 100644 --- a/tests/e2e/tests/databases.test.ts +++ b/tests/e2e/tests/databases.test.ts @@ -1,7 +1,7 @@ import { describe, it, before, after } from 'node:test' import assert from 'node:assert/strict' -import { sevalla } from '../helpers/cli.ts' -import { safeDelete } from '../helpers/cleanup.ts' +import { sevalla, rejectedBySchema } from '../helpers/cli.ts' +import { safeDelete, safeDeleteApp } from '../helpers/cleanup.ts' const RESOURCE = 'databases' const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) @@ -36,6 +36,7 @@ describe(RESOURCE, () => { let projectId: string let clusterId: string let resourceTypeId: string + let appId: string | undefined before(async () => { const projName = `e2e-db-proj-${Date.now()}` @@ -54,9 +55,24 @@ describe(RESOURCE, () => { const dbTypes = dbTypesRes.json<{ id: string }[]>() assert.ok(dbTypes.length > 0) resourceTypeId = (dbTypes[0] as { id: string }).id + + // Target for internal connection tests + const appRes = await sevalla([ + 'apps', 'create', + '--name', `e2e-db-conn-app-${Date.now()}`, + '--project', projectId, + '--cluster', clusterId, + '--source', 'dockerImage', + '--docker-image', 'nginx:latest', + ]) + assert.equal(appRes.exitCode, 0, `apps create failed: ${appRes.stderr}`) + appId = appRes.json<{ id: string }>().id }) after(async () => { + if (appId) { + await safeDeleteApp(appId) + } if (createdId) { await waitForDbTerminal(createdId).catch(() => {}) await safeDelete([RESOURCE], createdId) @@ -132,6 +148,18 @@ describe(RESOURCE, () => { assert.equal(res.json<{ id: string }>().id, createdId) }) + // --------------------------------------------------------------------------- + // Storage metrics + // --------------------------------------------------------------------------- + it('should get storage metrics', async () => { + assert.ok(createdId, 'No database was created') + for (const metric of ['all-storage', 'used-storage']) { + const res = await sevalla([RESOURCE, 'metrics', metric, createdId]) + assert.equal(res.exitCode, 0, `metrics ${metric} failed: ${res.stderr}`) + assert.equal(typeof res.json<{ value: number }>().value, 'number') + } + }) + // --------------------------------------------------------------------------- // IP Restriction // --------------------------------------------------------------------------- @@ -141,6 +169,18 @@ describe(RESOURCE, () => { assert.equal(res.exitCode, 0, `ip-restriction get failed: ${res.stderr}`) }) + it('should update IP restriction', async () => { + assert.ok(createdId, 'No database was created') + const res = await sevalla([ + RESOURCE, 'ip-restriction', 'update', createdId, + '--type', 'allow', + '--ip-list', '8.8.8.8,1.1.1.0/24', + '--enabled', 'false', + ]) + assert.equal(res.exitCode, 0, `ip-restriction update failed: ${res.stderr}`) + await waitForDbReady(createdId) + }) + // --------------------------------------------------------------------------- // Internal Connections // --------------------------------------------------------------------------- @@ -150,6 +190,25 @@ describe(RESOURCE, () => { assert.equal(res.exitCode, 0, `internal-list failed: ${res.stderr}`) }) + it('should create and delete an internal connection', async () => { + assert.ok(createdId, 'No database was created') + assert.ok(appId, 'No app was created') + const res = await sevalla([ + RESOURCE, 'connections', 'internal-create', createdId, + '--target-id', appId, + '--target-type', 'app', + ]) + assert.equal(res.exitCode, 0, `internal-create failed: ${res.stderr}`) + + const listRes = await sevalla([RESOURCE, 'connections', 'internal-list', '--db-id', createdId]) + assert.equal(listRes.exitCode, 0, `internal-list failed: ${listRes.stderr}`) + const conn = listRes.json<{ id: string; target_id: string }[]>().find((c) => c.target_id === appId) + assert.ok(conn, 'created connection should be listed') + + const delRes = await sevalla([RESOURCE, 'connections', 'internal-delete', createdId, conn.id, '--confirm']) + assert.equal(delRes.exitCode, 0, `internal-delete failed: ${delRes.stderr}`) + }) + // --------------------------------------------------------------------------- // External Connection Toggle // --------------------------------------------------------------------------- @@ -177,6 +236,37 @@ describe(RESOURCE, () => { assert.equal(res.exitCode, 0, `backups list failed: ${res.stderr}`) }) + it('should send a backup create request the API accepts', async () => { + assert.ok(createdId, 'No database was created') + await waitForDbReady(createdId) + // Clusters without snapshot backup support refuse on state, but never on request shape + const res = await sevalla([RESOURCE, 'backups', 'create', createdId, '--name', `e2e-backup-${Date.now()}`]) + assert.ok(!rejectedBySchema(res), `backups create rejected by schema: ${res.stderr}`) + }) + + it('should restore a backup', async (t) => { + assert.ok(createdId, 'No database was created') + const listRes = await sevalla([RESOURCE, 'backups', 'list', '--db-id', createdId]) + assert.equal(listRes.exitCode, 0, `backups list failed: ${listRes.stderr}`) + const backup = listRes.json<{ id: string }[]>()[0] + if (!backup) return t.skip('no backup available on this cluster') + + await waitForDbReady(createdId) + const res = await sevalla([RESOURCE, 'backups', 'restore', createdId, backup.id]) + assert.equal(res.exitCode, 0, `backups restore failed: ${res.stderr}`) + await waitForDbReady(createdId) + }) + + // --------------------------------------------------------------------------- + // Reset password + // --------------------------------------------------------------------------- + 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']) + assert.equal(res.exitCode, 0, `reset-password failed: ${res.stderr}`) + await waitForDbReady(createdId) + }) + // --------------------------------------------------------------------------- // Suspend & Activate // --------------------------------------------------------------------------- diff --git a/tests/e2e/tests/load-balancers.test.ts b/tests/e2e/tests/load-balancers.test.ts index e039d2d..89c7ee1 100644 --- a/tests/e2e/tests/load-balancers.test.ts +++ b/tests/e2e/tests/load-balancers.test.ts @@ -1,6 +1,6 @@ import { describe, it, before, after } from 'node:test' import assert from 'node:assert/strict' -import { sevalla } from '../helpers/cli.ts' +import { sevalla, rejectedBySchema } from '../helpers/cli.ts' import { safeDelete } from '../helpers/cleanup.ts' const RESOURCE = 'load-balancers' @@ -86,6 +86,33 @@ describe(RESOURCE, () => { assert.ok(Array.isArray(data)) }) + it('should add and delete a custom domain', async () => { + assert.ok(createdId, 'No LB was created') + const res = await sevalla([RESOURCE, 'domains', 'add', createdId, '--name', `e2e-lb-${Date.now()}.sevalla-cli-e2e.dev`]) + // Custom domains can be refused for account or environment reasons, so only the request shape is asserted + assert.ok(!rejectedBySchema(res), `domains add rejected by schema: ${res.stderr}`) + if (res.exitCode !== 0) return + + const domain = res.json<{ id: string }>() + const delRes = await sevalla([RESOURCE, 'domains', 'delete', createdId, domain.id, '--confirm']) + assert.equal(delRes.exitCode, 0, `domains delete failed: ${delRes.stderr}`) + }) + + it('should rename the system domain', async () => { + assert.ok(createdId, 'No LB was created') + const listRes = await sevalla([RESOURCE, 'domains', 'list', '--lb-id', createdId]) + assert.equal(listRes.exitCode, 0, `domains list failed: ${listRes.stderr}`) + const system = listRes.json<{ id: string; type: string }[]>().find((d) => d.type === 'SYSTEM') + assert.ok(system, 'LB should have a system domain') + + const res = await sevalla([ + RESOURCE, 'domains', 'update', system.id, + '--lb-id', createdId, + '--name', `e2e-lb-${Date.now()}`, + ]) + assert.equal(res.exitCode, 0, `domains update failed: ${res.stderr}`) + }) + // --------------------------------------------------------------------------- // Destinations // --------------------------------------------------------------------------- @@ -97,6 +124,22 @@ describe(RESOURCE, () => { assert.ok(Array.isArray(data)) }) + it('should create and delete an external destination', async () => { + assert.ok(createdId, 'No LB was created') + const res = await sevalla([ + RESOURCE, 'destinations', 'create', createdId, + '--service-type', 'EXTERNAL', + '--url', 'https://example.com', + '--weight', '10', + ]) + assert.equal(res.exitCode, 0, `destinations create failed: ${res.stderr}`) + const destination = res.json<{ id: string }>() + assert.ok(destination.id) + + const delRes = await sevalla([RESOURCE, 'destinations', 'delete', createdId, destination.id, '--confirm']) + assert.equal(delRes.exitCode, 0, `destinations delete failed: ${delRes.stderr}`) + }) + // --------------------------------------------------------------------------- // Delete // --------------------------------------------------------------------------- diff --git a/tests/e2e/tests/object-storage.test.ts b/tests/e2e/tests/object-storage.test.ts index 7c395e5..62af544 100644 --- a/tests/e2e/tests/object-storage.test.ts +++ b/tests/e2e/tests/object-storage.test.ts @@ -89,6 +89,12 @@ describe(RESOURCE, () => { } }) + it('should delete objects by key', async () => { + assert.ok(bucketId, 'No bucket was created') + const res = await sevalla([RESOURCE, 'objects', 'delete', bucketId, '--keys', 'e2e-a.txt,e2e-b.txt']) + assert.equal(res.exitCode, 0, `objects delete failed: ${res.stderr}`) + }) + // --------------------------------------------------------------------------- // CORS Policies // --------------------------------------------------------------------------- @@ -119,6 +125,19 @@ describe(RESOURCE, () => { assert.ok(Array.isArray(policies)) }) + it('should update a CORS policy', async () => { + assert.ok(bucketId, 'No bucket was created') + assert.ok(corsPolicyId, 'No CORS policy was created') + const res = await sevalla([ + RESOURCE, 'cors-policies', 'update', corsPolicyId, + '--bucket-id', bucketId, + '--origins', 'https://example.com,https://example.org', + '--methods', 'GET,HEAD', + '--headers', 'Content-Type', + ]) + assert.equal(res.exitCode, 0, `cors-policies update failed: ${res.stderr}`) + }) + it('should delete a CORS policy', async () => { assert.ok(bucketId, 'No bucket was created') if (!corsPolicyId) return // skip if create failed diff --git a/tests/e2e/tests/pipelines.test.ts b/tests/e2e/tests/pipelines.test.ts index 739187f..846b07e 100644 --- a/tests/e2e/tests/pipelines.test.ts +++ b/tests/e2e/tests/pipelines.test.ts @@ -1,6 +1,6 @@ import { describe, it, before, after } from 'node:test' import assert from 'node:assert/strict' -import { sevalla } from '../helpers/cli.ts' +import { sevalla, rejectedBySchema } from '../helpers/cli.ts' import { safeDelete, safeDeleteApp } from '../helpers/cleanup.ts' const RESOURCE = 'pipelines' @@ -129,6 +129,18 @@ describe(RESOURCE, () => { assert.equal(res.exitCode, 0, `stages apps add failed: ${res.stderr}`) }) + it('should send a promote request the API accepts', async () => { + assert.ok(createdId, 'No pipeline was created') + assert.ok(appId, 'No app was created') + // The app has no successful deployment, so the API may refuse on state, but never on request shape + const res = await sevalla([ + RESOURCE, 'promote', createdId, + '--source-app-id', appId, + '--target-app-ids', appId, + ], { timeout: 60_000 }) + assert.ok(!rejectedBySchema(res), `promote rejected by schema: ${res.stderr}`) + }) + it('should remove an app from a stage', async () => { assert.ok(createdId, 'No pipeline was created') assert.ok(stageId, 'No stage was created') @@ -151,6 +163,35 @@ describe(RESOURCE, () => { stageId = undefined }) + // --------------------------------------------------------------------------- + // Preview + // --------------------------------------------------------------------------- + it('should enable pipeline preview', async () => { + assert.ok(createdId, 'No pipeline was created') + const res = await sevalla([ + RESOURCE, 'preview', 'enable', createdId, + '--repo-url', 'https://github.com/kotapeter/pack-demo', + '--auto-create-app', 'false', + ], { timeout: 60_000 }) + assert.equal(res.exitCode, 0, `preview enable failed: ${res.stderr}`) + }) + + it('should update pipeline preview settings', async () => { + assert.ok(createdId, 'No pipeline was created') + const res = await sevalla([ + RESOURCE, 'preview', 'update', createdId, + '--delete-stale-apps', 'true', + '--stale-app-days', '7', + ], { timeout: 60_000 }) + assert.equal(res.exitCode, 0, `preview update failed: ${res.stderr}`) + }) + + it('should disable pipeline preview', async () => { + assert.ok(createdId, 'No pipeline was created') + const res = await sevalla([RESOURCE, 'preview', 'disable', createdId], { timeout: 60_000 }) + assert.equal(res.exitCode, 0, `preview disable failed: ${res.stderr}`) + }) + // --------------------------------------------------------------------------- // Delete // --------------------------------------------------------------------------- diff --git a/tests/e2e/tests/resources.test.ts b/tests/e2e/tests/resources.test.ts index dc4461a..71f7a02 100644 --- a/tests/e2e/tests/resources.test.ts +++ b/tests/e2e/tests/resources.test.ts @@ -48,4 +48,20 @@ describe('resources', () => { assert.ok(typeof procType.name === 'string', 'procType.name should be a string') } }) + + // --------------------------------------------------------------------------- + // Non-paginated lists (endpoints without limit/offset) + // --------------------------------------------------------------------------- + for (const args of [ + ['resources', 'rbac-permissions'], + ['resources', 'rbac-roles'], + ['users', 'list'], + ['git', 'providers'], + ]) { + it(`sevalla ${args.join(' ')} should return a list`, async () => { + const res = await sevalla(args) + assert.equal(res.exitCode, 0, `${args.join(' ')} failed: ${res.stderr}`) + assert.ok(Array.isArray(res.json()), 'expected an array') + }) + } }) diff --git a/tests/e2e/tests/static-sites.test.ts b/tests/e2e/tests/static-sites.test.ts index 809d2c7..35ef131 100644 --- a/tests/e2e/tests/static-sites.test.ts +++ b/tests/e2e/tests/static-sites.test.ts @@ -1,13 +1,13 @@ import { describe, it, before, after } from 'node:test' import assert from 'node:assert/strict' -import { sevalla } from '../helpers/cli.ts' +import { sevalla, rejectedBySchema } from '../helpers/cli.ts' import { safeDelete } from '../helpers/cleanup.ts' const RESOURCE = 'static-sites' const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) async function cancelActiveDeployments(siteId: string) { - const nonTerminal = ['queued', 'pending', 'building', 'built', 'deploying', 'rolling_out', 'in_progress'] + const nonTerminal = ['waiting', 'inProgress'] for (let attempt = 0; attempt < 10; attempt++) { const res = await sevalla([RESOURCE, 'deployments', 'list', '--site-id', siteId]) if (res.exitCode !== 0) return @@ -110,6 +110,33 @@ describe(RESOURCE, () => { assert.ok(Array.isArray(res.json())) }) + it('should add and delete a custom domain', async () => { + assert.ok(createdId, 'No static site was created') + const res = await sevalla([RESOURCE, 'domains', 'add', createdId, '--name', `e2e-ss-${Date.now()}.sevalla-cli-e2e.dev`]) + assert.equal(res.exitCode, 0, `domains add failed: ${res.stderr}`) + const domain = res.json<{ id: string }>() + assert.ok(domain.id) + + const delRes = await sevalla([RESOURCE, 'domains', 'delete', createdId, domain.id, '--confirm']) + assert.equal(delRes.exitCode, 0, `domains delete failed: ${delRes.stderr}`) + }) + + it('should rename the system domain', async () => { + assert.ok(createdId, 'No static site was created') + const listRes = await sevalla([RESOURCE, 'domains', 'list', '--site-id', createdId]) + assert.equal(listRes.exitCode, 0, `domains list failed: ${listRes.stderr}`) + const system = listRes.json<{ id: string; type: string }[]>().find((d) => d.type === 'SYSTEM') + assert.ok(system, 'static site should have a system domain') + + const res = await sevalla([ + RESOURCE, 'domains', 'update', system.id, + '--site-id', createdId, + '--name', `e2e-ss-${Date.now()}`, + ]) + // System domain renames can be refused for environment reasons, so only the request shape is asserted + assert.ok(!rejectedBySchema(res), `domains update rejected by schema: ${res.stderr}`) + }) + // --------------------------------------------------------------------------- // Deployments // --------------------------------------------------------------------------- diff --git a/tests/unit/client/api-client.test.ts b/tests/unit/client/api-client.test.ts index c2f5462..91805c1 100644 --- a/tests/unit/client/api-client.test.ts +++ b/tests/unit/client/api-client.test.ts @@ -63,6 +63,16 @@ describe('ApiClient', () => { assert.equal(result, undefined); }); + it('should handle 202 Accepted with an empty body', async () => { + const mockFetch = mock.fn(async () => new Response('', { status: 202 })); + globalThis.fetch = mockFetch as typeof fetch; + + const client = new ApiClient({ baseUrl: 'https://api.test.com', token: 'tok' }); + const result = await client.delete('/items/123'); + + assert.equal(result, undefined); + }); + it('should throw SevallaApiError on error responses', async () => { const mockFetch = mock.fn( async () => diff --git a/tests/unit/helpers/command-factory.test.ts b/tests/unit/helpers/command-factory.test.ts index 3608b3a..1715c17 100644 --- a/tests/unit/helpers/command-factory.test.ts +++ b/tests/unit/helpers/command-factory.test.ts @@ -264,4 +264,40 @@ describe('Command Factory', () => { globalThis.fetch = originalFetch; } }); + + it('should not send pagination or register page flags when paginated is false', async () => { + const originalFetch = globalThis.fetch; + let capturedUrl = ''; + + globalThis.fetch = (async (url: string) => { + capturedUrl = url; + return new Response(JSON.stringify([]), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }) as typeof fetch; + + try { + const cmd = makeListCommand({ + name: 'list', + description: 'List items', + columns: [{ header: 'ID', key: 'id' }], + apiCall: (client) => client.get('/items'), + paginated: false, + }); + + cmd.exitOverride(); + cmd.configureOutput({ writeErr: () => {}, writeOut: () => {} }); + + assert.equal( + cmd.options.some((o) => o.long === '--page'), + false, + ); + await cmd.parseAsync(['--json'], { from: 'user' }); + + assert.equal(new URL(capturedUrl).search, ''); + } finally { + globalThis.fetch = originalFetch; + } + }); }); From 02f0418496aad6dbadd1e1206088ef4f2ebac96d Mon Sep 17 00:00:00 2001 From: Peter Kota Date: Sun, 18 Oct 2026 10:37:34 +0200 Subject: [PATCH 2/2] fix: require --confirm for object delete and strict boolean flags - object-storage objects delete now needs --confirm like every other delete - --enabled on ip-restriction updates accepts only true/false - pipeline preview boolean flags reject values other than true/false - e2e: state-dependent asserts now require a real API refusal (not a crash, timeout or auth error), and custom-domain tests use a domain the team owns --- README.md | 2 +- src/commands/apps/ip-restriction/update.ts | 8 +++-- .../databases/ip-restriction/update.ts | 8 +++-- src/commands/object-storage/objects/delete.ts | 10 +++++-- src/commands/pipelines/preview/update.ts | 12 ++++++-- tests/e2e/helpers/cli.ts | 12 ++++++-- .../e2e/tests/application-deployments.test.ts | 4 +-- tests/e2e/tests/application-settings.test.ts | 2 +- tests/e2e/tests/databases.test.ts | 4 +-- tests/e2e/tests/load-balancers.test.ts | 6 ++-- tests/e2e/tests/object-storage.test.ts | 9 +++++- tests/e2e/tests/pipelines.test.ts | 4 +-- tests/e2e/tests/static-sites.test.ts | 6 ++-- tests/unit/commands/pipelines-preview.test.ts | 29 +++++++++++++++++++ 14 files changed, 90 insertions(+), 26 deletions(-) create mode 100644 tests/unit/commands/pipelines-preview.test.ts diff --git a/README.md b/README.md index 66f1a33..bc32f46 100644 --- a/README.md +++ b/README.md @@ -374,7 +374,7 @@ sevalla object-storage cors-policies delete ```bash sevalla object-storage objects list --bucket-id -sevalla object-storage objects delete --keys a.txt,b.txt +sevalla object-storage objects delete --keys a.txt,b.txt --confirm ``` ### Pipelines diff --git a/src/commands/apps/ip-restriction/update.ts b/src/commands/apps/ip-restriction/update.ts index c34c65f..cbdd3e4 100644 --- a/src/commands/apps/ip-restriction/update.ts +++ b/src/commands/apps/ip-restriction/update.ts @@ -1,4 +1,4 @@ -import { Command } from 'commander'; +import { Command, Option } from 'commander'; import { createClient } from '../../../client/api-client.ts'; import { handleError } from '../../../errors/handler.ts'; import { jsonOption, resolveJsonMode } from '../../../helpers/flags.ts'; @@ -10,7 +10,11 @@ export const ipRestrictionUpdateCommand = new Command('update') .argument('', 'Application ID') .requiredOption('--type ', 'Restriction type: allow or deny') .requiredOption('--ip-list ', 'Comma-separated IP addresses/CIDRs') - .requiredOption('--enabled ', 'Enable or disable (true/false)') + .addOption( + new Option('--enabled ', 'Enable or disable') + .choices(['true', 'false']) + .makeOptionMandatory(), + ) .addOption(jsonOption()) .action(async (appId: string, opts: Record) => { const client = createClient({ apiUrl: opts['apiUrl'] as string | undefined }); diff --git a/src/commands/databases/ip-restriction/update.ts b/src/commands/databases/ip-restriction/update.ts index 1bebb6e..36b85ff 100644 --- a/src/commands/databases/ip-restriction/update.ts +++ b/src/commands/databases/ip-restriction/update.ts @@ -1,4 +1,4 @@ -import { Command } from 'commander'; +import { Command, Option } from 'commander'; import { createClient } from '../../../client/api-client.ts'; import { handleError } from '../../../errors/handler.ts'; import { jsonOption, resolveJsonMode } from '../../../helpers/flags.ts'; @@ -10,7 +10,11 @@ export const ipRestrictionUpdateCommand = new Command('update') .argument('', 'Database ID') .requiredOption('--type ', 'Restriction type: allow or deny') .requiredOption('--ip-list ', 'Comma-separated IP addresses/CIDRs') - .requiredOption('--enabled ', 'Enable or disable (true/false)') + .addOption( + new Option('--enabled ', 'Enable or disable') + .choices(['true', 'false']) + .makeOptionMandatory(), + ) .addOption(jsonOption()) .action(async (dbId: string, opts: Record) => { const client = createClient({ apiUrl: opts['apiUrl'] as string | undefined }); diff --git a/src/commands/object-storage/objects/delete.ts b/src/commands/object-storage/objects/delete.ts index 7bd39e2..94d8656 100644 --- a/src/commands/object-storage/objects/delete.ts +++ b/src/commands/object-storage/objects/delete.ts @@ -1,4 +1,5 @@ import { makeActionCommand } from '../../../helpers/command-factory.ts'; +import { SevallaCliError } from '../../../errors/cli-error.ts'; export const objectsDeleteCommand = makeActionCommand({ name: 'delete', @@ -10,8 +11,13 @@ export const objectsDeleteCommand = makeActionCommand({ description: 'Comma-separated object keys to delete', required: true, }, + { flags: '--confirm', description: 'Confirm deletion' }, ], - apiCall: (client, id, opts) => - client.delete(`/object-storage/${id}/objects`, { keys: (opts['keys'] as string).split(',') }), + apiCall: (client, id, opts) => { + if (!opts['confirm']) throw new SevallaCliError('Use --confirm to confirm deletion.'); + return client.delete(`/object-storage/${id}/objects`, { + keys: (opts['keys'] as string).split(','), + }); + }, successMessage: 'Objects deleted.', }); diff --git a/src/commands/pipelines/preview/update.ts b/src/commands/pipelines/preview/update.ts index 80609c3..cb68066 100644 --- a/src/commands/pipelines/preview/update.ts +++ b/src/commands/pipelines/preview/update.ts @@ -1,4 +1,5 @@ import { makeUpdateCommand } from '../../../helpers/command-factory.ts'; +import { SevallaCliError } from '../../../errors/cli-error.ts'; // Shared with `preview enable`, which takes the same body export const previewOptions = [ @@ -14,13 +15,20 @@ export const previewOptions = [ }, ]; +function parseBool(value: unknown, flag: string): boolean { + if (value !== 'true' && value !== 'false') { + throw new SevallaCliError(`${flag} must be true or false.`); + } + return value === 'true'; +} + export function previewBody(opts: Record): Record { const body: Record = {}; if (opts['repoUrl']) body['repo_url'] = opts['repoUrl']; if (opts['autoCreateApp'] !== undefined) - body['auto_create_app'] = opts['autoCreateApp'] === 'true'; + body['auto_create_app'] = parseBool(opts['autoCreateApp'], '--auto-create-app'); if (opts['deleteStaleApps'] !== undefined) - body['delete_stale_apps'] = opts['deleteStaleApps'] === 'true'; + body['delete_stale_apps'] = parseBool(opts['deleteStaleApps'], '--delete-stale-apps'); if (opts['staleAppDays'] !== undefined) body['stale_app_days'] = Number(opts['staleAppDays']); return body; } diff --git a/tests/e2e/helpers/cli.ts b/tests/e2e/helpers/cli.ts index fccddd7..9980257 100644 --- a/tests/e2e/helpers/cli.ts +++ b/tests/e2e/helpers/cli.ts @@ -53,7 +53,13 @@ export function sevallaRaw(args: string[], opts?: { env?: Record }) } -// For state-dependent calls: true when the API rejected the request shape rather than the resource state -export function rejectedBySchema(res: CliResult): boolean { - return res.stderr.includes("doesn't match the schema") +// For state-dependent calls: success, or an API refusal that isn't auth or schema validation (crashes and timeouts fail) +export function refusedOnStateOnly(res: CliResult): boolean { + if (res.exitCode === 0) return true + try { + const { error } = JSON.parse(res.stderr) as { error: { status?: number } } + return typeof error.status === 'number' && ![401, 403].includes(error.status) && !res.stderr.includes("doesn't match the schema") + } catch { + return false + } } diff --git a/tests/e2e/tests/application-deployments.test.ts b/tests/e2e/tests/application-deployments.test.ts index 84fee3c..dbaef8d 100644 --- a/tests/e2e/tests/application-deployments.test.ts +++ b/tests/e2e/tests/application-deployments.test.ts @@ -1,6 +1,6 @@ import { describe, it, before, after } from 'node:test' import assert from 'node:assert/strict' -import { sevalla, rejectedBySchema } from '../helpers/cli.ts' +import { sevalla, refusedOnStateOnly } from '../helpers/cli.ts' import { safeDeleteApp, safeDelete, cancelAppDeployments } from '../helpers/cleanup.ts' interface Cluster { @@ -141,7 +141,7 @@ describe('application deployments and processes', () => { // No deployment has succeeded yet, so the API may refuse on state, but never on request shape const res = await sevalla(['apps', 'deployments', 'rollback', createdAppId], { timeout: 120_000 }) - assert.ok(!rejectedBySchema(res), `apps deployments rollback rejected by schema: ${res.stderr}`) + assert.ok(refusedOnStateOnly(res), `apps deployments rollback failed on request shape, auth or crash: ${res.stderr}`) }) // --- Processes --- diff --git a/tests/e2e/tests/application-settings.test.ts b/tests/e2e/tests/application-settings.test.ts index 85f0376..f508872 100644 --- a/tests/e2e/tests/application-settings.test.ts +++ b/tests/e2e/tests/application-settings.test.ts @@ -182,7 +182,7 @@ describe('application settings', () => { it('should add and delete a custom domain', async () => { assert.ok(appId, 'appId must be set from before hook') - const res = await sevalla(['apps', 'domains', 'add', appId, '--name', `e2e-${ts}.sevalla-cli-e2e.dev`]) + const res = await sevalla(['apps', 'domains', 'add', appId, '--name', `e2e-${ts}.sevalla.com`]) assert.equal(res.exitCode, 0, `domains add failed: ${res.stderr}`) const domain = res.json() assert.ok(domain.id, 'added domain should have an id') diff --git a/tests/e2e/tests/databases.test.ts b/tests/e2e/tests/databases.test.ts index 40d6454..a215284 100644 --- a/tests/e2e/tests/databases.test.ts +++ b/tests/e2e/tests/databases.test.ts @@ -1,6 +1,6 @@ import { describe, it, before, after } from 'node:test' import assert from 'node:assert/strict' -import { sevalla, rejectedBySchema } from '../helpers/cli.ts' +import { sevalla, refusedOnStateOnly } from '../helpers/cli.ts' import { safeDelete, safeDeleteApp } from '../helpers/cleanup.ts' const RESOURCE = 'databases' @@ -241,7 +241,7 @@ describe(RESOURCE, () => { await waitForDbReady(createdId) // Clusters without snapshot backup support refuse on state, but never on request shape const res = await sevalla([RESOURCE, 'backups', 'create', createdId, '--name', `e2e-backup-${Date.now()}`]) - assert.ok(!rejectedBySchema(res), `backups create rejected by schema: ${res.stderr}`) + assert.ok(refusedOnStateOnly(res), `backups create failed on request shape, auth or crash: ${res.stderr}`) }) it('should restore a backup', async (t) => { diff --git a/tests/e2e/tests/load-balancers.test.ts b/tests/e2e/tests/load-balancers.test.ts index 89c7ee1..0a602ea 100644 --- a/tests/e2e/tests/load-balancers.test.ts +++ b/tests/e2e/tests/load-balancers.test.ts @@ -1,6 +1,6 @@ import { describe, it, before, after } from 'node:test' import assert from 'node:assert/strict' -import { sevalla, rejectedBySchema } from '../helpers/cli.ts' +import { sevalla, refusedOnStateOnly } from '../helpers/cli.ts' import { safeDelete } from '../helpers/cleanup.ts' const RESOURCE = 'load-balancers' @@ -88,9 +88,9 @@ describe(RESOURCE, () => { it('should add and delete a custom domain', async () => { assert.ok(createdId, 'No LB was created') - const res = await sevalla([RESOURCE, 'domains', 'add', createdId, '--name', `e2e-lb-${Date.now()}.sevalla-cli-e2e.dev`]) + const res = await sevalla([RESOURCE, 'domains', 'add', createdId, '--name', `e2e-lb-${Date.now()}.sevalla.com`]) // Custom domains can be refused for account or environment reasons, so only the request shape is asserted - assert.ok(!rejectedBySchema(res), `domains add rejected by schema: ${res.stderr}`) + assert.ok(refusedOnStateOnly(res), `domains add failed on request shape, auth or crash: ${res.stderr}`) if (res.exitCode !== 0) return const domain = res.json<{ id: string }>() diff --git a/tests/e2e/tests/object-storage.test.ts b/tests/e2e/tests/object-storage.test.ts index 62af544..6fa062b 100644 --- a/tests/e2e/tests/object-storage.test.ts +++ b/tests/e2e/tests/object-storage.test.ts @@ -89,9 +89,16 @@ describe(RESOURCE, () => { } }) + 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']) + assert.equal(res.exitCode, 1) + assert.ok(res.stderr.includes('--confirm'), `expected a --confirm hint: ${res.stderr}`) + }) + it('should delete objects by key', async () => { assert.ok(bucketId, 'No bucket was created') - const res = await sevalla([RESOURCE, 'objects', 'delete', bucketId, '--keys', 'e2e-a.txt,e2e-b.txt']) + const res = await sevalla([RESOURCE, 'objects', 'delete', bucketId, '--keys', 'e2e-a.txt,e2e-b.txt', '--confirm']) assert.equal(res.exitCode, 0, `objects delete failed: ${res.stderr}`) }) diff --git a/tests/e2e/tests/pipelines.test.ts b/tests/e2e/tests/pipelines.test.ts index 846b07e..8c13c1e 100644 --- a/tests/e2e/tests/pipelines.test.ts +++ b/tests/e2e/tests/pipelines.test.ts @@ -1,6 +1,6 @@ import { describe, it, before, after } from 'node:test' import assert from 'node:assert/strict' -import { sevalla, rejectedBySchema } from '../helpers/cli.ts' +import { sevalla, refusedOnStateOnly } from '../helpers/cli.ts' import { safeDelete, safeDeleteApp } from '../helpers/cleanup.ts' const RESOURCE = 'pipelines' @@ -138,7 +138,7 @@ describe(RESOURCE, () => { '--source-app-id', appId, '--target-app-ids', appId, ], { timeout: 60_000 }) - assert.ok(!rejectedBySchema(res), `promote rejected by schema: ${res.stderr}`) + assert.ok(refusedOnStateOnly(res), `promote failed on request shape, auth or crash: ${res.stderr}`) }) it('should remove an app from a stage', async () => { diff --git a/tests/e2e/tests/static-sites.test.ts b/tests/e2e/tests/static-sites.test.ts index 35ef131..7637f82 100644 --- a/tests/e2e/tests/static-sites.test.ts +++ b/tests/e2e/tests/static-sites.test.ts @@ -1,6 +1,6 @@ import { describe, it, before, after } from 'node:test' import assert from 'node:assert/strict' -import { sevalla, rejectedBySchema } from '../helpers/cli.ts' +import { sevalla, refusedOnStateOnly } from '../helpers/cli.ts' import { safeDelete } from '../helpers/cleanup.ts' const RESOURCE = 'static-sites' @@ -112,7 +112,7 @@ describe(RESOURCE, () => { it('should add and delete a custom domain', async () => { assert.ok(createdId, 'No static site was created') - const res = await sevalla([RESOURCE, 'domains', 'add', createdId, '--name', `e2e-ss-${Date.now()}.sevalla-cli-e2e.dev`]) + const res = await sevalla([RESOURCE, 'domains', 'add', createdId, '--name', `e2e-ss-${Date.now()}.sevalla.com`]) assert.equal(res.exitCode, 0, `domains add failed: ${res.stderr}`) const domain = res.json<{ id: string }>() assert.ok(domain.id) @@ -134,7 +134,7 @@ describe(RESOURCE, () => { '--name', `e2e-ss-${Date.now()}`, ]) // System domain renames can be refused for environment reasons, so only the request shape is asserted - assert.ok(!rejectedBySchema(res), `domains update rejected by schema: ${res.stderr}`) + assert.ok(refusedOnStateOnly(res), `domains update failed on request shape, auth or crash: ${res.stderr}`) }) // --------------------------------------------------------------------------- diff --git a/tests/unit/commands/pipelines-preview.test.ts b/tests/unit/commands/pipelines-preview.test.ts new file mode 100644 index 0000000..c2b8622 --- /dev/null +++ b/tests/unit/commands/pipelines-preview.test.ts @@ -0,0 +1,29 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { previewBody } from '../../../src/commands/pipelines/preview/update.ts'; + +describe('Pipeline preview body', () => { + it('should map flags to spec fields', () => { + assert.deepEqual( + previewBody({ + repoUrl: 'https://github.com/a/b', + autoCreateApp: 'true', + deleteStaleApps: 'false', + staleAppDays: '7', + }), + { + repo_url: 'https://github.com/a/b', + auto_create_app: true, + delete_stale_apps: false, + stale_app_days: 7, + }, + ); + }); + + it('should reject boolean flags that are not true or false', () => { + assert.throws( + () => previewBody({ autoCreateApp: 'yes' }), + /--auto-create-app must be true or false/, + ); + }); +});