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..bc32f46 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 --confirm ``` ### 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..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'; @@ -8,18 +8,23 @@ 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') + .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 }); 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..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'; @@ -8,18 +8,23 @@ 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') + .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 }); 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..94d8656 100644 --- a/src/commands/object-storage/objects/delete.ts +++ b/src/commands/object-storage/objects/delete.ts @@ -1,11 +1,23 @@ import { makeActionCommand } from '../../../helpers/command-factory.ts'; +import { SevallaCliError } from '../../../errors/cli-error.ts'; export const objectsDeleteCommand = makeActionCommand({ name: 'delete', description: 'Delete objects from a bucket', idArg: 'bucket-id', - options: [{ flags: '--keys ', description: 'Object keys to delete' }], - apiCall: (client, id, opts) => - client.delete(`/object-storage/${id}/objects`, { keys: opts['keys'] }), + options: [ + { + flags: '--keys ', + description: 'Comma-separated object keys to delete', + required: true, + }, + { flags: '--confirm', description: 'Confirm deletion' }, + ], + 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/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..cb68066 100644 --- a/src/commands/pipelines/preview/update.ts +++ b/src/commands/pipelines/preview/update.ts @@ -1,17 +1,42 @@ 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 = [ + { 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', + }, +]; + +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'] = parseBool(opts['autoCreateApp'], '--auto-create-app'); + if (opts['deleteStaleApps'] !== undefined) + body['delete_stale_apps'] = parseBool(opts['deleteStaleApps'], '--delete-stale-apps'); + 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..9980257 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,14 @@ export function sevallaRaw(args: string[], opts?: { env?: Record }) }) } + +// 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/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..dbaef8d 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, refusedOnStateOnly } 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(refusedOnStateOnly(res), `apps deployments rollback failed on request shape, auth or crash: ${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..f508872 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.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') + + 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..a215284 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, refusedOnStateOnly } 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(refusedOnStateOnly(res), `backups create failed on request shape, auth or crash: ${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..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 } from '../helpers/cli.ts' +import { sevalla, refusedOnStateOnly } 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.com`]) + // Custom domains can be refused for account or environment reasons, so only the request shape is asserted + 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 }>() + 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..6fa062b 100644 --- a/tests/e2e/tests/object-storage.test.ts +++ b/tests/e2e/tests/object-storage.test.ts @@ -89,6 +89,19 @@ 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', '--confirm']) + assert.equal(res.exitCode, 0, `objects delete failed: ${res.stderr}`) + }) + // --------------------------------------------------------------------------- // CORS Policies // --------------------------------------------------------------------------- @@ -119,6 +132,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..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 } from '../helpers/cli.ts' +import { sevalla, refusedOnStateOnly } 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(refusedOnStateOnly(res), `promote failed on request shape, auth or crash: ${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..7637f82 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, refusedOnStateOnly } 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.com`]) + 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(refusedOnStateOnly(res), `domains update failed on request shape, auth or crash: ${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/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/, + ); + }); +}); 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; + } + }); });